feat(optimiseur): machine de repli §7, état et plan publiés vers l'app (brief 4 T4)
- OptimizerStatus::Vivacite : optimizerAlive, échecs comptés PAR REQUÊTE (E17), deux consécutifs = mort, socket fermé = mort, retour au premier plan valide ; une abstention rompt la série sans ressusciter. strategieActive() : none sous L2, prioritaire sur tout ; rules dès qu'un optimiseur n'est plus vivant, sans attendre le cycle suivant. - SocketScheduler : branchement de la vivacité, cache abandonné à la mort, UpdateLearned journalisé une fois et ignoré. - NymeaEnergy.GetOptimizerStatus + OptimizerStatusChanged (une notification par transition, charge utile déclarée une fois) ; champs installateur absents s'ils sont inconnus ; jamais le token, tokenConfigured seulement. Pas de tier (Q5). - SetOptimizerEndpoint (persisté, appliqué à chaud) et SetOptimizerToken (écriture seule, fichier 0600, Q2), tous deux PermissionScopeAdmin ; token déclaré Variant pour qu'un mauvais type ne soit pas recopié au journal par le validateur nymea. - NymeaEnergy.GetPlan : plan en vigueur (appliqué au créneau courant, demandé ensuite), codes et paramètres filtrés seulement, inForce toujours présent, fallbackCode quand un optimiseur configuré n'a pas commandé. - Tests : tableau §7 ligne par ligne, transitions notifiées une fois, RPC, token absent de tout journal du moteur et de toute réponse. Sans endpoint : decisions IDENTIQUE (23 792 lignes), suites inchangées, journal >= info inchangé (8 739 lignes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
696cb001f1
commit
c1ce67942c
@ -13,6 +13,8 @@
|
||||
#include "scheduler/rulebasedscheduler.h"
|
||||
#include "scheduler/optimizerclient.h"
|
||||
#include "scheduler/socketscheduler.h"
|
||||
#include "scheduler/optimizerstatus.h"
|
||||
#include "scheduler/publishedplan.h"
|
||||
#include "types/decisionreason.h"
|
||||
#include "types/surpluscontext.h"
|
||||
#include "types/plan.h"
|
||||
@ -25,6 +27,7 @@
|
||||
#include <energylogs.h>
|
||||
#include <QTimer>
|
||||
#include <QJsonDocument>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace {
|
||||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||||
@ -130,6 +133,18 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||
// Brief 4 T3 — la stratégie optimiseur n'existe que si un endpoint est configuré.
|
||||
relireReglageOptimiseur();
|
||||
|
||||
// Brief 4 T4 — §7 : le mode dégradé L2 l'emporte sur tout (activeStrategy = none). La boucle
|
||||
// de sécurité n'est ni appelée ni modifiée : on écoute la notification qu'elle émet déjà à
|
||||
// chaque bascule. Sous L2 aucun plan n'est en vigueur ; « none » tient jusqu'au premier cycle
|
||||
// PLANIFIÉ qui suit, puisque la sortie de L2 ne replanifie qu'au cycle suivant.
|
||||
connect(this, &SmartChargingManager::chargingSchedulesChanged, this, [this]() {
|
||||
if (m_degradedMode) {
|
||||
m_strategieDuCycle = QLatin1String(OptimizerStatus::StrategieAucune);
|
||||
m_planPublie.clear();
|
||||
}
|
||||
suivreStatutOptimiseur();
|
||||
});
|
||||
|
||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||||
}
|
||||
|
||||
@ -526,6 +541,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||||
buildTelemetry(plan, slot, currentDateTime);
|
||||
publishTelemetry(false);
|
||||
|
||||
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
||||
// Rien ici ne commande : c'est de la publication, après tout le reste.
|
||||
noterPlanDuCycle(plan, slot, currentDateTime);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@ -2830,8 +2849,10 @@ void EnergyArbitrator::relireReglageOptimiseur()
|
||||
m_socketScheduler = nullptr;
|
||||
}
|
||||
m_optimizerEndpoint = texte;
|
||||
if (texte.isEmpty())
|
||||
if (texte.isEmpty()) {
|
||||
suivreStatutOptimiseur();
|
||||
return;
|
||||
}
|
||||
|
||||
const OptimizerEndpoint endpoint = OptimizerEndpoint::parse(texte);
|
||||
if (!endpoint.isValid()) {
|
||||
@ -2839,6 +2860,7 @@ void EnergyArbitrator::relireReglageOptimiseur()
|
||||
qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] Optimizer/endpoint illisible :" << texte
|
||||
<< "— attendu unix:///chemin ou tcp://hôte:port ; "
|
||||
"aucune stratégie optimiseur, rule-based seul";
|
||||
suivreStatutOptimiseur();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2866,9 +2888,120 @@ void EnergyArbitrator::relireReglageOptimiseur()
|
||||
connect(m_tm, &ThingManager::thingRemoved, s,
|
||||
[s]() { s->noterDeclencheur(QStringLiteral("load-change")); });
|
||||
}
|
||||
connect(s, &SocketScheduler::etatChange, this, &EnergyArbitrator::suivreStatutOptimiseur);
|
||||
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie optimiseur active vers"
|
||||
<< endpoint.toString()
|
||||
<< "— rule-based en ombre à chaque cycle, repli sur lui";
|
||||
suivreStatutOptimiseur();
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::setOptimizerEndpoint(const QString &texte, QString *code)
|
||||
{
|
||||
const QString t = texte.trimmed();
|
||||
if (!t.isEmpty() && !OptimizerEndpoint::parse(t).isValid()) {
|
||||
// 7-d : le refus est dit, au moins aussi haut qu'une acceptation. L'endpoint n'est pas un
|
||||
// secret : le citer aide l'installateur à voir sa faute de frappe.
|
||||
qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] endpoint REFUSÉ :" << t
|
||||
<< "— attendu unix:///chemin ou tcp://hôte:port ; réglage inchangé";
|
||||
if (code)
|
||||
*code = QStringLiteral("invalid-endpoint");
|
||||
return false;
|
||||
}
|
||||
EnergySettings reglages;
|
||||
if (t.isEmpty())
|
||||
reglages.remove(QStringLiteral("Optimizer/endpoint"));
|
||||
else
|
||||
reglages.setValue(QStringLiteral("Optimizer/endpoint"), t);
|
||||
reglages.sync();
|
||||
relireReglageOptimiseur();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::setOptimizerToken(const QByteArray &token, QString *code)
|
||||
{
|
||||
// Le token ne traverse que cette fonction et writeToken() : jamais une ligne de journal, ni
|
||||
// sa longueur. Seul le FAIT (posé, retiré, refusé) est dit.
|
||||
QString erreur;
|
||||
if (!OptimizerClient::writeToken(OptimizerClient::defaultTokenPath(), token, &erreur)) {
|
||||
// Le motif de writeToken() ne cite jamais le secret (forme refusée, ou chemin et errno).
|
||||
qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] token REFUSÉ :" << erreur << "— token inchangé";
|
||||
if (code)
|
||||
*code = QStringLiteral("token-refused");
|
||||
return false;
|
||||
}
|
||||
qCInfo(dcNymeaEnergy()) << (token.isEmpty() ? "[Optimiseur] token d'optimiseur retiré"
|
||||
: "[Optimiseur] token d'optimiseur posé (fichier 0600, "
|
||||
"envoyé à la prochaine connexion)");
|
||||
return true;
|
||||
}
|
||||
|
||||
QVariantMap EnergyArbitrator::optimizerStatus() const
|
||||
{
|
||||
QVariantMap st;
|
||||
const bool attendu = !m_optimizerEndpoint.isEmpty();
|
||||
st.insert(QStringLiteral("optimizerExpected"), attendu);
|
||||
const bool vivant = m_socketScheduler && m_socketScheduler->vivacite().vivant();
|
||||
st.insert(QStringLiteral("optimizerAlive"), vivant);
|
||||
st.insert(QStringLiteral("activeStrategy"),
|
||||
OptimizerStatus::strategieActive(m_degradedMode, vivant, m_strategieDuCycle));
|
||||
const QFileInfo token(OptimizerClient::defaultTokenPath());
|
||||
st.insert(QStringLiteral("tokenConfigured"), token.exists() && token.size() > 0);
|
||||
if (!attendu)
|
||||
return st; // sans optimiseur, le reste n'a pas de valeur : absent, jamais 0
|
||||
if (!m_socketScheduler) {
|
||||
// Endpoint illisible dans energy.conf (la RPC refuse de l'écrire) : attendu, jamais joint.
|
||||
st.insert(QStringLiteral("lastErrorCode"), QStringLiteral("invalid-endpoint"));
|
||||
return st;
|
||||
}
|
||||
const OptimizerStatus::Vivacite &v = m_socketScheduler->vivacite();
|
||||
st.insert(QStringLiteral("consecutiveFailures"), v.echecsConsecutifs());
|
||||
if (!v.dernierCodeErreur().isEmpty())
|
||||
st.insert(QStringLiteral("lastErrorCode"), v.dernierCodeErreur());
|
||||
if (v.dernierPlanA().isValid())
|
||||
st.insert(QStringLiteral("lastPlanAt"), v.dernierPlanA().toUTC().toString(Qt::ISODate));
|
||||
const OptimizerClient *c = m_socketScheduler->client();
|
||||
// Identité du DERNIER handshake accepté : rien tant qu'aucun ne l'a été.
|
||||
const QString nom = OptimizerStatus::identite(c->serverName(), 64);
|
||||
if (!nom.isEmpty())
|
||||
st.insert(QStringLiteral("name"), nom);
|
||||
const QString version = OptimizerStatus::identite(c->serverProtocolVersion(), 32);
|
||||
if (!version.isEmpty())
|
||||
st.insert(QStringLiteral("protocolVersion"), version);
|
||||
if (c->hasCapabilities())
|
||||
st.insert(QStringLiteral("capabilities"), OptimizerStatus::capacitesPubliees(c->capabilities()));
|
||||
return st;
|
||||
}
|
||||
|
||||
void EnergyArbitrator::suivreStatutOptimiseur()
|
||||
{
|
||||
const QVariantMap st = optimizerStatus();
|
||||
const QString triplet = QStringLiteral("%1|%2|%3")
|
||||
.arg(st.value(QStringLiteral("optimizerExpected")).toBool())
|
||||
.arg(st.value(QStringLiteral("optimizerAlive")).toBool())
|
||||
.arg(st.value(QStringLiteral("activeStrategy")).toString());
|
||||
if (triplet == m_statutOptimiseurEmis)
|
||||
return;
|
||||
const bool premier = m_statutOptimiseurEmis.isEmpty(); // l'état initial n'est pas une transition
|
||||
m_statutOptimiseurEmis = triplet;
|
||||
if (!premier)
|
||||
emit optimizerStatusChanged(st);
|
||||
}
|
||||
|
||||
void EnergyArbitrator::noterPlanDuCycle(const Plan &plan, const Slot &slot, const QDateTime &now)
|
||||
{
|
||||
const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur);
|
||||
m_strategieDuCycle = QLatin1String(optimiseur ? OptimizerStatus::StrategieOptimiseur
|
||||
: OptimizerStatus::StrategieRegles);
|
||||
if (optimiseur && m_socketScheduler) {
|
||||
const OptimizerPlan::PlanRecu recu = m_socketScheduler->planApplique();
|
||||
m_planPublie = PublishedPlan::serialiser(plan, slot, m_actionsAppliquees, &recu, QString(), now);
|
||||
} else {
|
||||
m_planPublie = PublishedPlan::serialiser(plan, slot, m_actionsAppliquees, nullptr,
|
||||
m_socketScheduler ? m_socketScheduler->dernierCodeDeRepli()
|
||||
: QString(),
|
||||
now);
|
||||
}
|
||||
suivreStatutOptimiseur();
|
||||
}
|
||||
|
||||
OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContext &ctx) const
|
||||
|
||||
@ -261,14 +261,62 @@ public:
|
||||
* optimiseur (brief 4 T3, preuve \c decisions). Présent et valide : la stratégie optimiseur est
|
||||
* construite et son client démarré ; un endpoint inchangé ne reconstruit rien. Invalide :
|
||||
* signalé une fois, rien n'est construit.
|
||||
* \note Appelée par le constructeur. Lecture seule dans ce lot : la RPC d'écriture (T4)
|
||||
* rappellera cette méthode.
|
||||
* \note Appelée par le constructeur, et par \c setOptimizerEndpoint() (RPC, à chaud).
|
||||
*/
|
||||
void relireReglageOptimiseur();
|
||||
|
||||
/*! \brief \return La stratégie optimiseur, ou \c nullptr sans endpoint configuré. */
|
||||
SocketScheduler *socketScheduler() const { return m_socketScheduler; }
|
||||
|
||||
/*!
|
||||
* \brief Pose l'endpoint de l'optimiseur, le persiste (\c Optimizer/endpoint) et l'applique
|
||||
* à chaud (\c relireReglageOptimiseur()) — \c NymeaEnergy.SetOptimizerEndpoint.
|
||||
* \param texte \c unix:///chemin ou \c tcp://hôte:port ; vide = aucun optimiseur (le réglage
|
||||
* est retiré, la stratégie et son client détruits).
|
||||
* \param[out] code \c invalid-endpoint si \p texte est illisible (\c OptimizerEndpoint::parse) ;
|
||||
* rien n'est alors modifié.
|
||||
* \return Vrai si le réglage est posé ou retiré.
|
||||
*/
|
||||
bool setOptimizerEndpoint(const QString &texte, QString *code = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief Pose ou retire le token de l'optimiseur — \c NymeaEnergy.SetOptimizerToken, écriture
|
||||
* SEULE (décision Q2).
|
||||
* \param token Secret ; vide = retrait du fichier. Écrit par \c OptimizerClient::writeToken()
|
||||
* dans \c OptimizerClient::defaultTokenPath() (0600), jamais dans \c energy.conf.
|
||||
* \param[out] code \c token-refused (blanc, caractère de contrôle, plus de 4 096 octets, ou
|
||||
* écriture impossible — le journal dit lequel) ; rien n'est alors modifié.
|
||||
* \return Vrai si le fichier est posé ou retiré.
|
||||
* \note Le token n'est ni relu, ni rendu, ni journalisé ; il sert à la prochaine (re)connexion
|
||||
* du client, qui le relit à chaque tentative.
|
||||
*/
|
||||
bool setOptimizerToken(const QByteArray &token, QString *code = nullptr);
|
||||
|
||||
/*!
|
||||
* \brief État de la machine de repli §7, tel que l'app le lit — \c GetOptimizerStatus et la
|
||||
* charge utile de \c OptimizerStatusChanged.
|
||||
*
|
||||
* Toujours : \c optimizerExpected (un endpoint est configuré), \c optimizerAlive (§7),
|
||||
* \c activeStrategy (\c optimizer | \c rules | \c none — \c none en mode dégradé L2,
|
||||
* prioritaire sur tout), \c tokenConfigured. Avec un endpoint (mode installateur côté app) :
|
||||
* \c consecutiveFailures, et s'ils sont connus \c name, \c protocolVersion, \c capabilities,
|
||||
* \c lastPlanAt, \c lastErrorCode. Une valeur inconnue est ABSENTE, jamais 0. Jamais le token.
|
||||
* \return La charge utile.
|
||||
*/
|
||||
QVariantMap optimizerStatus() const;
|
||||
|
||||
/*!
|
||||
* \brief Le plan EN VIGUEUR publié au dernier cycle — \c NymeaEnergy.GetPlan.
|
||||
* \return \c PublishedPlan::serialiser() du dernier cycle planifié (\c inForce vrai) ; avant
|
||||
* le premier cycle et en mode dégradé L2, \c {inForce: false} et rien d'autre. Jamais une
|
||||
* carte vide : elle ne se distinguerait pas d'une déclaration perdue (balayage
|
||||
* \c testEveryGetterReturnsWhatItDeclares). Ce qui a été publié, pas un recalcul.
|
||||
*/
|
||||
QVariantMap publishedPlan() const
|
||||
{
|
||||
return m_planPublie.isEmpty() ? QVariantMap{{QStringLiteral("inForce"), false}} : m_planPublie;
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief Ce que la requête \c GetPlan porte en plus du contexte (OPTIMIZER_PROTOCOL §5) :
|
||||
* compteur racine, PV instantanée, limite de phase, réserve, domaine et mesure par charge,
|
||||
@ -776,7 +824,12 @@ signals:
|
||||
*/
|
||||
void loadTelemetryChanged(const QVariantMap &telemetry);
|
||||
|
||||
|
||||
/*!
|
||||
* \brief Une TRANSITION de la machine §7 : \c optimizerExpected, \c optimizerAlive ou
|
||||
* \c activeStrategy a changé. Pas à chaque cycle.
|
||||
* \param status Charge utile identique à celle d'\c optimizerStatus().
|
||||
*/
|
||||
void optimizerStatusChanged(const QVariantMap &status);
|
||||
|
||||
protected:
|
||||
/*!
|
||||
@ -1075,6 +1128,20 @@ private:
|
||||
*/
|
||||
void publishTelemetry(bool force);
|
||||
|
||||
/*!
|
||||
* \brief Recompose l'état §7 et émet \c optimizerStatusChanged() s'il a TRANSITÉ.
|
||||
* \note Appelée aux changements de vivacité, de réglage, de stratégie du cycle et de mode
|
||||
* dégradé ; sans transition, rien n'est émis.
|
||||
*/
|
||||
void suivreStatutOptimiseur();
|
||||
|
||||
/*!
|
||||
* \brief Fin de cycle planifié : stratégie qui a commandé, plan publié (\c GetPlan), transition.
|
||||
* \param plan Plan du cycle. \param slot Créneau dispatché, délestage L4 fusionné.
|
||||
* \param now Instant du cycle.
|
||||
*/
|
||||
void noterPlanDuCycle(const Plan &plan, const Slot &slot, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief Vrai si deux instantanés diffèrent assez pour mériter une notification.
|
||||
* \param a Dernier instantané publié. \param b Instantané courant.
|
||||
@ -1104,6 +1171,13 @@ private:
|
||||
QString m_optimizerEndpoint;
|
||||
//! Le réglage a-t-il déjà été lu ? Un endpoint illisible n'est signalé qu'à sa première lecture.
|
||||
bool m_optimizerReglageLu = false;
|
||||
//! Stratégie qui a commandé le dernier cycle planifié (\c optimizer | \c rules), \c none
|
||||
//! depuis l'entrée en mode dégradé L2 jusqu'au premier cycle planifié qui suit.
|
||||
QString m_strategieDuCycle = QStringLiteral("rules");
|
||||
//! Triplet (attendu, vivant, stratégie) de la dernière notification : une émission par transition.
|
||||
QString m_statutOptimiseurEmis;
|
||||
//! Plan en vigueur publié au dernier cycle (\c publishedPlan()) ; vide sans plan.
|
||||
QVariantMap m_planPublie;
|
||||
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||
EnergyManager *m_em = nullptr;
|
||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||
|
||||
@ -21,6 +21,8 @@ HEADERS += \
|
||||
$$PWD/scheduler/optimizerclient.h \
|
||||
$$PWD/scheduler/optimizerplan.h \
|
||||
$$PWD/scheduler/socketscheduler.h \
|
||||
$$PWD/scheduler/optimizerstatus.h \
|
||||
$$PWD/scheduler/publishedplan.h \
|
||||
$$PWD/ratios/energyratioscalculator.h \
|
||||
$$PWD/energyarbitrator.h \
|
||||
$$PWD/spotmarketbuild.h \
|
||||
@ -42,6 +44,8 @@ SOURCES += \
|
||||
$$PWD/scheduler/optimizerclient.cpp \
|
||||
$$PWD/scheduler/optimizerplan.cpp \
|
||||
$$PWD/scheduler/socketscheduler.cpp \
|
||||
$$PWD/scheduler/optimizerstatus.cpp \
|
||||
$$PWD/scheduler/publishedplan.cpp \
|
||||
$$PWD/energyarbitrator.cpp \
|
||||
$$PWD/spotmarketbuild.cpp \
|
||||
|
||||
|
||||
103
energyplugin/etm/scheduler/optimizerstatus.cpp
Normal file
103
energyplugin/etm/scheduler/optimizerstatus.cpp
Normal file
@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "optimizerstatus.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <cmath>
|
||||
|
||||
namespace OptimizerStatus {
|
||||
|
||||
bool Vivacite::noter(Evenement e, const QString &code, const QDateTime &instant)
|
||||
{
|
||||
const bool avant = m_vivant;
|
||||
switch (e) {
|
||||
case Evenement::PlanValide:
|
||||
m_vivant = true;
|
||||
m_echecs = 0;
|
||||
m_dernierPlanA = instant;
|
||||
break;
|
||||
case Evenement::Abstention:
|
||||
// L'optimiseur a répondu : la série d'échecs est rompue. Il ne planifie pas pour autant —
|
||||
// un optimiseur mort qui s'abstient reste mort jusqu'à son premier plan valide.
|
||||
m_echecs = 0;
|
||||
break;
|
||||
case Evenement::Echec:
|
||||
++m_echecs;
|
||||
if (!code.isEmpty())
|
||||
m_dernierCode = code;
|
||||
if (m_echecs >= EchecsAvantMort)
|
||||
m_vivant = false;
|
||||
break;
|
||||
case Evenement::ConnexionPerdue:
|
||||
m_vivant = false;
|
||||
if (!code.isEmpty())
|
||||
m_dernierCode = code;
|
||||
break;
|
||||
}
|
||||
return m_vivant != avant;
|
||||
}
|
||||
|
||||
QString strategieActive(bool degrade, bool vivant, const QString &strategieDuCycle)
|
||||
{
|
||||
if (degrade)
|
||||
return QString::fromLatin1(StrategieAucune);
|
||||
if (!vivant && strategieDuCycle == QLatin1String(StrategieOptimiseur))
|
||||
return QString::fromLatin1(StrategieRegles);
|
||||
return strategieDuCycle;
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool listeDeCodes(const QVariant &v, const QStringList &connus, QVariantList *out)
|
||||
{
|
||||
if (v.userType() != QMetaType::QVariantList && v.userType() != QMetaType::QStringList)
|
||||
return false;
|
||||
for (const QVariant &x : v.toList()) {
|
||||
if (x.userType() != QMetaType::QString || !connus.contains(x.toString()))
|
||||
continue; // un code inconnu ou une valeur non textuelle n'est pas recopiée
|
||||
if (!out->contains(x))
|
||||
out->append(x);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
QVariantMap capacitesPubliees(const QVariantMap &capabilities)
|
||||
{
|
||||
static const QStringList kinds{QStringLiteral("setpoint"), QStringLiteral("state"), QStringLiteral("constraint")};
|
||||
static const QStringList sources{QStringLiteral("pv"), QStringLiteral("baseload"), QStringLiteral("price"),
|
||||
QStringLiteral("temperature")};
|
||||
QVariantMap out;
|
||||
for (const char *cle : {"maxHorizonH", "resolutionS"}) {
|
||||
const QVariant v = capabilities.value(QLatin1String(cle));
|
||||
bool ok = false;
|
||||
const double d = v.toDouble(&ok);
|
||||
const bool nombre = v.userType() == QMetaType::Double || v.userType() == QMetaType::Int
|
||||
|| v.userType() == QMetaType::LongLong || v.userType() == QMetaType::UInt
|
||||
|| v.userType() == QMetaType::ULongLong;
|
||||
if (nombre && ok && std::isfinite(d) && d >= 0)
|
||||
out.insert(QLatin1String(cle), d);
|
||||
}
|
||||
QVariantList l;
|
||||
if (listeDeCodes(capabilities.value(QStringLiteral("kinds")), kinds, &l))
|
||||
out.insert(QStringLiteral("kinds"), l);
|
||||
l.clear();
|
||||
if (listeDeCodes(capabilities.value(QStringLiteral("usesForecast")), sources, &l))
|
||||
out.insert(QStringLiteral("usesForecast"), l);
|
||||
if (capabilities.value(QStringLiteral("reasonCodes")).userType() == QMetaType::Bool)
|
||||
out.insert(QStringLiteral("reasonCodes"), capabilities.value(QStringLiteral("reasonCodes")).toBool());
|
||||
return out;
|
||||
}
|
||||
|
||||
QString identite(const QString &texte, int max)
|
||||
{
|
||||
QString s;
|
||||
s.reserve(texte.size());
|
||||
for (const QChar c : texte)
|
||||
s.append(c.category() == QChar::Other_Control || c.category() == QChar::Other_Format
|
||||
|| c.category() == QChar::Separator_Line || c.category() == QChar::Separator_Paragraph
|
||||
? QChar(' ') : c);
|
||||
return s.simplified().left(max);
|
||||
}
|
||||
|
||||
}
|
||||
110
energyplugin/etm/scheduler/optimizerstatus.h
Normal file
110
energyplugin/etm/scheduler/optimizerstatus.h
Normal file
@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
/*!
|
||||
* \file optimizerstatus.h
|
||||
* \brief Machine de repli §7 (OPTIMIZER_PROTOCOL.md 1.1) et ce qu'elle publie vers l'app
|
||||
* (\c NymeaEnergy.GetOptimizerStatus, brief 4 T4).
|
||||
*
|
||||
* Logique PURE : ni E/S, ni horloge (les instants sont fournis par l'appelant), ni accès à
|
||||
* l'arbitre. Le \c SocketScheduler y verse les événements de sa connexion ; l'arbitre compose
|
||||
* l'état publié (attendu, vivant, stratégie, mode dégradé L2).
|
||||
*/
|
||||
namespace OptimizerStatus {
|
||||
|
||||
//! \c activeStrategy : le créneau courant d'un plan d'optimiseur a commandé le dernier cycle.
|
||||
constexpr const char *StrategieOptimiseur = "optimizer";
|
||||
//! \c activeStrategy : le rule-based a commandé le dernier cycle (seul, ou en repli).
|
||||
constexpr const char *StrategieRegles = "rules";
|
||||
//! \c activeStrategy : planification suspendue — mode dégradé L2, prioritaire sur tout (§7).
|
||||
constexpr const char *StrategieAucune = "none";
|
||||
|
||||
//! Requêtes consécutives en échec au-delà desquelles l'optimiseur est déclaré mort (§4, §7, E17).
|
||||
constexpr int EchecsAvantMort = 2;
|
||||
|
||||
/*!
|
||||
* \brief Vivacité de l'optimiseur — la colonne \c optimizerAlive du tableau §7.
|
||||
*
|
||||
* | Événement | \c vivant() | échecs consécutifs |
|
||||
* |---|---|---|
|
||||
* | plan valide reçu | vrai | remis à 0 |
|
||||
* | abstention, plan expiré à la réception | inchangé | remis à 0 |
|
||||
* | requête en échec (délai, erreur, réponse illisible, plan invalide) | faux au 2ᵉ | +1 |
|
||||
* | connexion absente ou fermée | faux | inchangé |
|
||||
*
|
||||
* Les échecs se comptent par REQUÊTE, pas par cycle (écart E17) : une requête remplacée par une
|
||||
* plus récente avant sa réponse n'est pas un échec (le client n'émet rien, brief 4 T2).
|
||||
* \invariant Seul un plan valide rend la vie : une abstention dit que l'optimiseur répond, pas
|
||||
* qu'il planifie, et ne ressuscite donc pas un optimiseur déclaré mort.
|
||||
*/
|
||||
class Vivacite
|
||||
{
|
||||
public:
|
||||
/*! \brief Ce qui arrive à la connexion ou à une requête. */
|
||||
enum class Evenement {
|
||||
PlanValide, //!< Réponse \c GetPlan jugée valide.
|
||||
Abstention, //!< Abstention, plan vide, aucun créneau courant, ou plan expiré à la réception.
|
||||
Echec, //!< Requête sans réponse utilisable, ou plan invalide.
|
||||
ConnexionPerdue //!< Socket absent, fermé, ou handshake refusé.
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief Applique un événement.
|
||||
* \param e Événement.
|
||||
* \param code Code d'erreur retenu comme \c lastErrorCode (\c Echec, \c ConnexionPerdue) ;
|
||||
* vide = inchangé.
|
||||
* \param instant Instant du plan valide (\c lastPlanAt) ; ignoré pour les autres événements.
|
||||
* \return Vrai si \c vivant() a changé.
|
||||
*/
|
||||
bool noter(Evenement e, const QString &code = QString(), const QDateTime &instant = QDateTime());
|
||||
|
||||
/*! \brief \return \c optimizerAlive. Faux tant qu'aucun plan valide n'a été reçu. */
|
||||
bool vivant() const { return m_vivant; }
|
||||
/*! \brief \return Requêtes consécutives en échec depuis la dernière réponse valide. */
|
||||
int echecsConsecutifs() const { return m_echecs; }
|
||||
/*! \brief \return Dernier code d'erreur vu (requête, plan ou connexion) ; vide si aucun. */
|
||||
QString dernierCodeErreur() const { return m_dernierCode; }
|
||||
/*! \brief \return Instant du cycle dont la requête a reçu le dernier plan valide ; invalide si aucun. */
|
||||
QDateTime dernierPlanA() const { return m_dernierPlanA; }
|
||||
|
||||
private:
|
||||
bool m_vivant = false;
|
||||
int m_echecs = 0;
|
||||
QString m_dernierCode;
|
||||
QDateTime m_dernierPlanA;
|
||||
};
|
||||
|
||||
/*!
|
||||
* \brief La colonne « stratégie active » du tableau §7, composée sans attendre le cycle suivant.
|
||||
* \param degrade Mode dégradé L2 en cours. \param vivant \c optimizerAlive.
|
||||
* \param strategieDuCycle Stratégie qui a commandé le dernier cycle planifié (\c optimizer,
|
||||
* \c rules, ou \c none si aucun cycle n'a été planifié depuis l'entrée en L2).
|
||||
* \return \c none sous L2, prioritaire sur tout ; \c rules si le dernier cycle était à
|
||||
* l'optimiseur mais qu'il n'est plus vivant (mort, connexion perdue, endpoint retiré) — son
|
||||
* plan en cache a été abandonné, le cycle suivant sera au rule-based, et publier
|
||||
* « optimizer » d'ici là décrirait un état qui n'existe plus ; sinon \p strategieDuCycle.
|
||||
*/
|
||||
QString strategieActive(bool degrade, bool vivant, const QString &strategieDuCycle);
|
||||
|
||||
/*!
|
||||
* \brief \c capabilities du handshake réduites à ce que l'app peut afficher (mode installateur).
|
||||
* \param capabilities \c result.capabilities tel que reçu.
|
||||
* \return Les seules clés documentées au §3 — \c maxHorizonH, \c resolutionS (nombres finis),
|
||||
* \c kinds, \c usesForecast (listes de codes connus), \c reasonCodes (booléen). Toute autre clé
|
||||
* ou valeur est retirée : rien de ce qu'un optimiseur écrit librement n'atteint l'app.
|
||||
*/
|
||||
QVariantMap capacitesPubliees(const QVariantMap &capabilities);
|
||||
|
||||
/*!
|
||||
* \brief Chaîne d'identité d'un optimiseur (\c name, \c protocolVersion) rendue sûre pour l'app.
|
||||
* \param texte Valeur reçue au handshake. \param max Longueur gardée.
|
||||
* \return Caractères de contrôle retirés, blancs resserrés, coupée à \p max ; vide si rien ne reste.
|
||||
*/
|
||||
QString identite(const QString &texte, int max);
|
||||
|
||||
}
|
||||
149
energyplugin/etm/scheduler/publishedplan.cpp
Normal file
149
energyplugin/etm/scheduler/publishedplan.cpp
Normal file
@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include "publishedplan.h"
|
||||
|
||||
#include <QStringList>
|
||||
#include <cmath>
|
||||
|
||||
namespace PublishedPlan {
|
||||
|
||||
namespace {
|
||||
|
||||
QString iso(const QDateTime &t) { return t.toUTC().toString(Qt::ISODate); }
|
||||
|
||||
QString nomKind(LoadAction::Kind k)
|
||||
{
|
||||
switch (k) {
|
||||
case LoadAction::Setpoint: return QStringLiteral("setpoint");
|
||||
case LoadAction::State: return QStringLiteral("state");
|
||||
case LoadAction::Constraint: return QStringLiteral("constraint");
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Une action vers l'app : la charge, sa puissance, l'état SG-Ready, le motif EN CODE. Le motif
|
||||
// est celui de l'action (correctif a ; filtré à la réception pour un optimiseur, T3).
|
||||
QVariantMap entree(const LoadAction &a, double puissanceW, int etat)
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("loadId"), a.loadId);
|
||||
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||||
m.insert(QStringLiteral("powerW"), puissanceW);
|
||||
if (a.kind == LoadAction::State)
|
||||
m.insert(QStringLiteral("state"), etat);
|
||||
m.insert(QStringLiteral("reasonCode"), a.reason.code);
|
||||
if (!a.reason.params.isEmpty())
|
||||
m.insert(QStringLiteral("reasonParams"), a.reason.params);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Le créneau courant : ce que les adaptateurs ont APPLIQUÉ. Une charge délestée par L4 ou non
|
||||
// dispatchée n'a pas de retour d'applyAction : son action du créneau (fusionnée) fait foi.
|
||||
QVariantList actionsAppliquees(const Slot &slot, const QHash<QString, LoadAction> &appliquees)
|
||||
{
|
||||
QVariantList l;
|
||||
for (const LoadAction &a : slot.actions) {
|
||||
const auto r = appliquees.constFind(a.loadId);
|
||||
if (r != appliquees.constEnd())
|
||||
l.append(entree(a, qMax(0.0, r->estimatedPowerW), r->state));
|
||||
else
|
||||
l.append(entree(a, qMax(0.0, a.estimatedPowerW), a.state));
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
QVariantMap creneau(const QDateTime &from, const QDateTime &to, const QVariantList &actions,
|
||||
const QVariantMap &expected)
|
||||
{
|
||||
QVariantMap c;
|
||||
c.insert(QStringLiteral("from"), iso(from));
|
||||
c.insert(QStringLiteral("to"), iso(to));
|
||||
c.insert(QStringLiteral("actions"), actions);
|
||||
const QVariantMap e = attenduPublie(expected);
|
||||
if (!e.isEmpty())
|
||||
c.insert(QStringLiteral("expected"), e);
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QVariantMap attenduPublie(const QVariantMap &expected)
|
||||
{
|
||||
QVariantMap out;
|
||||
for (const char *cle : {"gridImportW", "gridExportW"}) {
|
||||
const QVariant v = expected.value(QLatin1String(cle));
|
||||
if (v.userType() != QMetaType::Double && v.userType() != QMetaType::Int
|
||||
&& v.userType() != QMetaType::LongLong)
|
||||
continue;
|
||||
const double d = v.toDouble();
|
||||
if (std::isfinite(d))
|
||||
out.insert(QLatin1String(cle), d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QVariantMap diagnosticsPublies(const QVariantMap &diagnostics)
|
||||
{
|
||||
static const QStringList statuts{QStringLiteral("optimal"), QStringLiteral("feasible"),
|
||||
QStringLiteral("best-so-far"), QStringLiteral("infeasible-relaxed")};
|
||||
static const QStringList pv{QStringLiteral("physics"), QStringLiteral("hybrid"), QStringLiteral("none")};
|
||||
QVariantMap out;
|
||||
const QVariant statut = diagnostics.value(QStringLiteral("status"));
|
||||
if (statut.userType() == QMetaType::QString && statuts.contains(statut.toString()))
|
||||
out.insert(QStringLiteral("status"), statut.toString());
|
||||
const QVariant entrees = diagnostics.value(QStringLiteral("inputsUsed"));
|
||||
if (entrees.userType() == QMetaType::QVariantMap) {
|
||||
const QVariant p = entrees.toMap().value(QStringLiteral("pv"));
|
||||
if (p.userType() == QMetaType::QString && pv.contains(p.toString()))
|
||||
out.insert(QStringLiteral("inputsUsed"), QVariantMap{{QStringLiteral("pv"), p.toString()}});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QVariantMap serialiser(const Plan &plan, const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||
const OptimizerPlan::PlanRecu *recu, const QString &codeRepli, const QDateTime &now)
|
||||
{
|
||||
QVariantMap m;
|
||||
m.insert(QStringLiteral("inForce"), true);
|
||||
const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur) && recu
|
||||
&& recu->indexCourant >= 0 && recu->indexCourant < recu->creneaux.size();
|
||||
if (!optimiseur) {
|
||||
m.insert(QStringLiteral("strategy"), QLatin1String(StrategieRegles));
|
||||
m.insert(QStringLiteral("computedAt"), iso(now));
|
||||
if (slot.to.isValid())
|
||||
m.insert(QStringLiteral("validUntil"), iso(slot.to));
|
||||
m.insert(QStringLiteral("slots"), QVariantList{
|
||||
creneau(slot.from.isValid() ? slot.from : now, slot.to.isValid() ? slot.to : now,
|
||||
actionsAppliquees(slot, appliquees), QVariantMap())});
|
||||
if (!codeRepli.isEmpty())
|
||||
m.insert(QStringLiteral("fallbackCode"), codeRepli);
|
||||
return m;
|
||||
}
|
||||
|
||||
m.insert(QStringLiteral("strategy"), QLatin1String(OptimizerPlan::StrategieOptimiseur));
|
||||
if (recu->computedAt.isValid())
|
||||
m.insert(QStringLiteral("computedAt"), iso(recu->computedAt));
|
||||
if (recu->validUntil.isValid())
|
||||
m.insert(QStringLiteral("validUntil"), iso(recu->validUntil));
|
||||
|
||||
const OptimizerPlan::CreneauRecu &courant = recu->creneaux.at(recu->indexCourant);
|
||||
QVariantList creneaux{creneau(courant.from, courant.to, actionsAppliquees(slot, appliquees),
|
||||
courant.expected)};
|
||||
// Les créneaux SUIVANTS : ce que l'optimiseur DEMANDE, informatif (§6) — rien n'y est encore
|
||||
// appliqué. Les passés ne se publient pas : ils ne sont plus en vigueur.
|
||||
for (int i = recu->indexCourant + 1; i < recu->creneaux.size(); ++i) {
|
||||
const OptimizerPlan::CreneauRecu &c = recu->creneaux.at(i);
|
||||
QVariantList actions;
|
||||
for (const OptimizerPlan::ActionRecue &ar : c.actions)
|
||||
actions.append(entree(ar.action, qMax(0.0, ar.action.estimatedPowerW), ar.action.state));
|
||||
creneaux.append(creneau(c.from, c.to, actions, c.expected));
|
||||
}
|
||||
m.insert(QStringLiteral("slots"), creneaux);
|
||||
const QVariantMap diag = diagnosticsPublies(recu->diagnostics);
|
||||
if (!diag.isEmpty())
|
||||
m.insert(QStringLiteral("diagnostics"), diag);
|
||||
return m;
|
||||
}
|
||||
|
||||
}
|
||||
66
energyplugin/etm/scheduler/publishedplan.h
Normal file
66
energyplugin/etm/scheduler/publishedplan.h
Normal file
@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include "optimizerplan.h"
|
||||
|
||||
/*!
|
||||
* \file publishedplan.h
|
||||
* \brief Le plan EN VIGUEUR tel que l'app le lit (\c NymeaEnergy.GetPlan, brief 4 T4, exigence
|
||||
* app 2).
|
||||
*
|
||||
* Fonctions PURES. L'arbitre appelle \c serialiser() en fin de cycle et garde le résultat : le
|
||||
* \c Get rend ce qui a été publié, il ne recalcule rien (même règle que \c GetLoadTelemetry).
|
||||
*
|
||||
* \par Ce qui franchit la frontière — invariant 12
|
||||
* Des codes, des nombres, des instants. Le \c reason libre d'un optimiseur n'y est jamais ;
|
||||
* \c expected et \c diagnostics sont réduits à leurs clés documentées (§6), le reste est retiré ;
|
||||
* ni \c planId, ni \c strategy, ni \c solver de l'optimiseur — des chaînes qu'il écrit librement.
|
||||
*/
|
||||
namespace PublishedPlan {
|
||||
|
||||
//! Valeur de \c strategy d'un plan du rule-based publié (celle de \c Plan::strategy).
|
||||
constexpr const char *StrategieRegles = "rule-based";
|
||||
|
||||
/*!
|
||||
* \brief Le plan du cycle, pour l'app.
|
||||
*
|
||||
* - **Sous un optimiseur** (\p plan.strategy == \c "optimizer", \p recu non nul) : le créneau
|
||||
* courant, actions À LA PUISSANCE APPLIQUÉE (celles que l'adaptateur a rendues, après bornes,
|
||||
* verrous, paliers et délestage L4), puis les créneaux suivants du plan reçu, actions à la
|
||||
* puissance DEMANDÉE. \c computedAt et \c validUntil sont ceux de l'optimiseur, absents s'il ne
|
||||
* les a pas donnés.
|
||||
* - **Sous le rule-based** : un seul créneau, le courant, actions à la puissance appliquée ;
|
||||
* \c computedAt = \p now, \c validUntil = fin du créneau. \c fallbackCode porte \p codeRepli
|
||||
* s'il n'est pas vide (un optimiseur est configuré mais n'a pas commandé ce cycle).
|
||||
*
|
||||
* \param plan Plan du cycle (après \c finaliser()). \param slot Créneau dispatché, délestage L4
|
||||
* fusionné. \param appliquees Retours d'\c applyAction() du cycle.
|
||||
* \param recu Plan d'optimiseur appliqué ce cycle ; ignoré sous le rule-based.
|
||||
* \param codeRepli Motif du repli rule-based d'une stratégie optimiseur ; vide sans optimiseur.
|
||||
* \param now Instant du cycle.
|
||||
* \return La charge utile de \c GetPlan, \c inForce à vrai.
|
||||
*/
|
||||
QVariantMap serialiser(const Plan &plan, const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||||
const OptimizerPlan::PlanRecu *recu, const QString &codeRepli, const QDateTime &now);
|
||||
|
||||
/*!
|
||||
* \brief \c expected d'un créneau réduit aux clés documentées (§6).
|
||||
* \param expected Tel que reçu. \return \c gridImportW, \c gridExportW s'ils sont des nombres finis.
|
||||
*/
|
||||
QVariantMap attenduPublie(const QVariantMap &expected);
|
||||
|
||||
/*!
|
||||
* \brief \c diagnostics réduits à ce que l'app lit (§6, exigence app 4).
|
||||
* \param diagnostics Tels que reçus.
|
||||
* \return \c status s'il est l'une des quatre valeurs du §6, et \c inputsUsed.pv s'il vaut
|
||||
* \c physics, \c hybrid (« plan calculé sur une prévision corrigée ») ou \c none ; rien
|
||||
* d'autre — une valeur hors de cette liste fermée est un texte libre, elle est retirée.
|
||||
*/
|
||||
QVariantMap diagnosticsPublies(const QVariantMap &diagnostics);
|
||||
|
||||
}
|
||||
@ -28,11 +28,18 @@ SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, Fou
|
||||
m_client->setParent(this);
|
||||
connect(m_client, &OptimizerClient::planReceived, this, &SocketScheduler::onPlanRecu);
|
||||
connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue);
|
||||
// §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté.
|
||||
// §7 « socket absent / fermé → rules, optimizerAlive = false » : un plan ne survit pas à la
|
||||
// connexion qui l'a porté, et un optimiseur qu'on n'atteint pas n'est pas vivant.
|
||||
connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) {
|
||||
if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking)
|
||||
if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking) {
|
||||
oublierCache(QStringLiteral("connexion perdue"));
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode());
|
||||
}
|
||||
});
|
||||
// Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose.
|
||||
connect(m_client, &OptimizerClient::ready, this, &SocketScheduler::etatChange);
|
||||
connect(m_client, &OptimizerClient::notificationReceived, this,
|
||||
[this](const QString &method, const QVariantMap &) { onNotification(method); });
|
||||
m_client->start();
|
||||
}
|
||||
|
||||
@ -127,6 +134,20 @@ void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
|
||||
m_client->hasCapabilities());
|
||||
signalerReserves(v.champsReserves);
|
||||
emit reponseJugee(OptimizerPlan::nomIssue(v.issue), v.code);
|
||||
// §7 : un plan valide rend la vie ; une abstention ou un plan déjà expiré rompent la série
|
||||
// d'échecs sans la rendre ; un plan invalide est un échec de la requête.
|
||||
switch (v.issue) {
|
||||
case OptimizerPlan::Issue::Valide:
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::PlanValide, QString(), m_ctxRequete.timestamp);
|
||||
break;
|
||||
case OptimizerPlan::Issue::Abstention:
|
||||
case OptimizerPlan::Issue::Expire:
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::Abstention, QString());
|
||||
break;
|
||||
case OptimizerPlan::Issue::Invalide:
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, v.code);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (v.issue) {
|
||||
case OptimizerPlan::Issue::Valide: {
|
||||
@ -172,8 +193,48 @@ void SocketScheduler::onPlanEchoue(quint64 id, const QString &code)
|
||||
if (id != m_idRequete)
|
||||
return;
|
||||
// Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable
|
||||
// jusqu'à son échéance : un échec isolé n'est pas un optimiseur mort (§7, T4).
|
||||
// jusqu'à son échéance : un échec ISOLÉ n'est pas un optimiseur mort. Le second l'est (§7).
|
||||
emit echecRequete(code);
|
||||
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
|
||||
}
|
||||
|
||||
void SocketScheduler::noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
|
||||
const QDateTime &instant)
|
||||
{
|
||||
const bool change = m_vivacite.noter(e, code, instant);
|
||||
if (change && m_vivacite.vivant()) {
|
||||
qCInfo(dcNymeaEnergy()) << "[Optimiseur] optimiseur VIVANT (optimizerAlive = true) : plan valide reçu";
|
||||
} else if (change) {
|
||||
// Mort : le plan en cache n'est plus cru, même s'il n'a pas expiré — le tableau §7 dit
|
||||
// « rules », pas « le dernier plan jusqu'à son échéance ».
|
||||
oublierCache(QString());
|
||||
if (e == OptimizerStatus::Vivacite::Evenement::Echec)
|
||||
qCWarning(dcNymeaEnergy()).noquote()
|
||||
<< "[Optimiseur] optimiseur déclaré MORT (optimizerAlive = false) :"
|
||||
<< m_vivacite.echecsConsecutifs() << "requêtes consécutives en échec, dernière :"
|
||||
<< m_vivacite.dernierCodeErreur() << "— repli rule-based jusqu'au prochain plan valide";
|
||||
else
|
||||
qCInfo(dcNymeaEnergy()).noquote()
|
||||
<< "[Optimiseur] optimizerAlive = false : connexion absente ou fermée ("
|
||||
+ (m_vivacite.dernierCodeErreur().isEmpty() ? QStringLiteral("-") : m_vivacite.dernierCodeErreur())
|
||||
+ ") — repli rule-based";
|
||||
}
|
||||
emit etatChange();
|
||||
}
|
||||
|
||||
void SocketScheduler::onNotification(const QString &method)
|
||||
{
|
||||
if (method != QLatin1String("UpdateLearned"))
|
||||
return; // le client l'a déjà notée en debug ; aucune autre notification n'est définie (§8)
|
||||
// §8 : reçu, ignoré — la persistance des estimations apprises est un lot ultérieur. Le
|
||||
// contenu n'est jamais journalisé : il vient d'ailleurs et peut être volumineux.
|
||||
if (m_updateLearnedSignale) {
|
||||
qCDebug(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et ignoré";
|
||||
return;
|
||||
}
|
||||
m_updateLearnedSignale = true;
|
||||
qCInfo(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et IGNORÉ (persistance hors de ce lot)"
|
||||
<< "— signalé une fois par session";
|
||||
}
|
||||
|
||||
void SocketScheduler::oublierCache(const QString &pourquoi)
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
#include "optimizerclient.h"
|
||||
#include "optimizerplan.h"
|
||||
#include "optimizerprotocol.h"
|
||||
#include "optimizerstatus.h"
|
||||
|
||||
/*!
|
||||
* \brief Stratégie « optimiseur externe » (OPTIMIZER_PROTOCOL.md 1.1, lot 3d, brief 4 T3).
|
||||
@ -31,10 +32,11 @@
|
||||
*
|
||||
* Après le dispatch, \c apresDispatch() relit ce que les adaptateurs ont appliqué (Q3, Q4).
|
||||
*
|
||||
* **Repli minimal de ce lot** : plan absent, invalide, expiré, abstention, connexion perdue → le
|
||||
* rule-based pour le cycle. La machine §7 complète (compteur d'échecs, \c optimizerAlive,
|
||||
* notification) est T4 ; ses points d'accroche sont les signaux \c reponseJugee(),
|
||||
* \c echecRequete() et \c strategieDuCycle().
|
||||
* **Repli (§7)** : plan absent, invalide, expiré, abstention, connexion perdue → le rule-based
|
||||
* pour le cycle. La vivacité (\c optimizerAlive, brief 4 T4) est tenue par
|
||||
* \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue,
|
||||
* déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend
|
||||
* vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié.
|
||||
*
|
||||
* \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante.
|
||||
* \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based.
|
||||
@ -108,6 +110,9 @@ public:
|
||||
*/
|
||||
OptimizerPlan::PlanRecu planApplique() const { return m_planApplique; }
|
||||
|
||||
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
|
||||
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
|
||||
|
||||
signals:
|
||||
/*!
|
||||
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
|
||||
@ -124,6 +129,10 @@ signals:
|
||||
* été substitué. \param code Motif du repli, vide si optimiseur. */
|
||||
void strategieDuCycle(bool optimiseur, const QString &code);
|
||||
|
||||
/*! \brief La vivacité, les échecs, le dernier code d'erreur ou l'identité de l'optimiseur
|
||||
* (handshake) ont pu changer — l'arbitre recompose l'état publié. */
|
||||
void etatChange();
|
||||
|
||||
private:
|
||||
void envoyer(const SurplusContext &ctx);
|
||||
void onPlanRecu(quint64 id, const QVariantMap &result);
|
||||
@ -132,6 +141,9 @@ private:
|
||||
void noterCycle(bool optimiseur, const QString &code, const QString &detail);
|
||||
void signalerReserves(const QStringList &champs);
|
||||
void journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx);
|
||||
void noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
|
||||
const QDateTime &instant = QDateTime());
|
||||
void onNotification(const QString &method);
|
||||
|
||||
IScheduler *m_ombre = nullptr;
|
||||
OptimizerClient *m_client = nullptr;
|
||||
@ -153,4 +165,6 @@ private:
|
||||
QSet<QString> m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé
|
||||
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
|
||||
QSet<QString> m_reservesSignales; // une fois par session
|
||||
OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête
|
||||
bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session
|
||||
};
|
||||
|
||||
@ -548,6 +548,80 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
registerMethod("ClearLoadDerogation", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
// --- Optimiseur externe (brief 4 T4, OPTIMIZER_PROTOCOL §7) --------------------------------
|
||||
// L'état de la machine de repli et le plan en vigueur : des booléens, des codes, des nombres.
|
||||
// Aucun texte de l'optimiseur, jamais le token.
|
||||
params.clear(); returns.clear();
|
||||
description = "State of the external-optimizer fallback machine (OPTIMIZER_PROTOCOL 7). "
|
||||
"optimizerExpected: an endpoint is configured. optimizerAlive: the optimizer "
|
||||
"answered with a valid plan and has not failed two consecutive requests nor lost "
|
||||
"its connection since. activeStrategy: optimizer | rules | none (none = degraded "
|
||||
"mode L2, planning suspended). The other fields are for the installer view; an "
|
||||
"unknown value is OMITTED, never 0. The token is never returned: tokenConfigured "
|
||||
"only says whether one is set. Same payload as OptimizerStatusChanged.";
|
||||
returns = optimizerStatusSchema();
|
||||
registerMethod("GetOptimizerStatus", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
params.clear(); returns.clear();
|
||||
description = "Set the external-optimizer endpoint, persisted and applied immediately: "
|
||||
"unix:///path or tcp://host:port (IPv6 in brackets). An EMPTY string removes it "
|
||||
"(rule-based only). On refusal: EnergyErrorInvalidParameter, fault "
|
||||
"invalid-endpoint, and nothing is changed.";
|
||||
params.insert("endpoint", enumValueName(String));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
returns.insert("o:fault", enumValueName(String));
|
||||
registerMethod("SetOptimizerEndpoint", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
// WRITE-ONLY (décision Q2) : aucune méthode ne relit le token. Admin : c'est un secret, et il
|
||||
// ouvre l'optimiseur à qui le détient — même niveau que les réglages d'installation du
|
||||
// handler restés au défaut nymea (SetPhasePowerLimit, SetBatteryLevelConsideration).
|
||||
// `token` est déclaré Variant, et son type vérifié ici : un paramètre que le VALIDATEUR nymea
|
||||
// refuse est recopié en entier dans un avertissement (« Call params: », dcJsonRpc) — un token
|
||||
// envoyé en nombre finirait au journal. Une clé en trop, elle, y finit quand même : le
|
||||
// validateur la refuse avant que le handler ne voie l'appel (INTERFACE.md).
|
||||
params.clear(); returns.clear();
|
||||
description = "Set the shared secret sent to the optimizer at handshake. WRITE-ONLY: no method "
|
||||
"returns it (GetOptimizerStatus has tokenConfigured). Stored in its own file, mode "
|
||||
"0600, never in energy.conf; used from the next (re)connection. An EMPTY string "
|
||||
"removes it. A string of printable bytes, no whitespace, at most 4096 bytes; on "
|
||||
"refusal: EnergyErrorInvalidParameter, fault token-refused, nothing is changed. "
|
||||
"Send ONLY the 'token' key: nymea logs, as a warning, the params of a call whose "
|
||||
"keys it rejects.";
|
||||
params.insert("token", enumValueName(Variant));
|
||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||
returns.insert("o:fault", enumValueName(String));
|
||||
registerMethod("SetOptimizerToken", description, params, returns, Types::PermissionScopeAdmin);
|
||||
|
||||
QVariantMap planAction;
|
||||
planAction.insert("loadId", enumValueName(String));
|
||||
planAction.insert("kind", enumValueName(String)); // setpoint | state
|
||||
planAction.insert("powerW", enumValueName(Double));
|
||||
planAction.insert("o:state", enumValueName(Int)); // sg-ready
|
||||
planAction.insert("reasonCode", enumValueName(String));
|
||||
planAction.insert("o:reasonParams", enumValueName(Variant)); // clés selon le code (INTERFACE.md)
|
||||
QVariantMap planSlot;
|
||||
planSlot.insert("from", enumValueName(String));
|
||||
planSlot.insert("to", enumValueName(String));
|
||||
planSlot.insert("actions", QVariantList() << planAction);
|
||||
planSlot.insert("o:expected", enumValueName(Variant)); // gridImportW, gridExportW
|
||||
params.clear(); returns.clear();
|
||||
description = "The plan IN FORCE at the last planned cycle. Under an optimizer: the current "
|
||||
"slot with the power APPLIED by the adapters, then the following slots with the "
|
||||
"power REQUESTED (informative). Under the rule-based: one slot, strategy "
|
||||
"rule-based, and fallbackCode when an optimizer is configured but did not command "
|
||||
"this cycle. Reasons are codes and params only, never text. inForce is false, "
|
||||
"and nothing else is returned, before the first cycle and in degraded mode L2 (no "
|
||||
"plan in force). No notification: read it again on LoadTelemetryChanged or "
|
||||
"OptimizerStatusChanged.";
|
||||
returns.insert("inForce", enumValueName(Bool));
|
||||
returns.insert("o:strategy", enumValueName(String)); // optimizer | rule-based
|
||||
returns.insert("o:computedAt", enumValueName(String));
|
||||
returns.insert("o:validUntil", enumValueName(String));
|
||||
returns.insert("o:slots", QVariantList() << planSlot);
|
||||
returns.insert("o:diagnostics", enumValueName(Variant)); // status, inputsUsed.pv
|
||||
returns.insert("o:fallbackCode", enumValueName(String));
|
||||
registerMethod("GetPlan", description, params, returns, Types::PermissionScopeControlThings);
|
||||
|
||||
|
||||
// Notifications
|
||||
params.clear();
|
||||
@ -679,6 +753,22 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
||||
}
|
||||
#endif
|
||||
|
||||
// [ETM] OptimizerStatusChanged : à chaque TRANSITION de la machine §7 (attendu, vivant,
|
||||
// stratégie active), jamais à chaque cycle. Même charge utile que GetOptimizerStatus.
|
||||
params.clear();
|
||||
description = "Emitted at each transition of the optimizer fallback machine: optimizerExpected, "
|
||||
"optimizerAlive or activeStrategy changed. Not emitted every cycle. Same payload "
|
||||
"as GetOptimizerStatus.";
|
||||
params = optimizerStatusSchema();
|
||||
registerNotification("OptimizerStatusChanged", description, params);
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||
connect(arb, &EnergyArbitrator::optimizerStatusChanged, this, [this](const QVariantMap &st) {
|
||||
emit OptimizerStatusChanged(st);
|
||||
});
|
||||
}
|
||||
#endif
|
||||
|
||||
// [Phase 2] EnergyRatiosChanged : émise sur powerBalanceChanged() de l'energymanager,
|
||||
// uniquement quand une valeur (ou sa disponibilité) change. Mêmes champs que GetEnergyRatios.
|
||||
params.clear();
|
||||
@ -1088,6 +1178,28 @@ JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap ¶ms)
|
||||
return createReply(ret);
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::optimizerStatusSchema()
|
||||
{
|
||||
QVariantMap capabilities;
|
||||
capabilities.insert("o:maxHorizonH", enumValueName(Double));
|
||||
capabilities.insert("o:resolutionS", enumValueName(Double));
|
||||
capabilities.insert("o:kinds", QVariantList() << enumValueName(String));
|
||||
capabilities.insert("o:usesForecast", QVariantList() << enumValueName(String));
|
||||
capabilities.insert("o:reasonCodes", enumValueName(Bool));
|
||||
QVariantMap s;
|
||||
s.insert("optimizerExpected", enumValueName(Bool));
|
||||
s.insert("optimizerAlive", enumValueName(Bool));
|
||||
s.insert("activeStrategy", enumValueName(String)); // optimizer | rules | none
|
||||
s.insert("tokenConfigured", enumValueName(Bool));
|
||||
s.insert("o:name", enumValueName(String));
|
||||
s.insert("o:protocolVersion", enumValueName(String));
|
||||
s.insert("o:capabilities", capabilities);
|
||||
s.insert("o:lastPlanAt", enumValueName(String));
|
||||
s.insert("o:consecutiveFailures", enumValueName(Int));
|
||||
s.insert("o:lastErrorCode", enumValueName(String));
|
||||
return s;
|
||||
}
|
||||
|
||||
QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
||||
{
|
||||
// Verrou : le TEMPS RESTANT, pas des watts. lockMinPowerW/lockMaxPowerW sont un artefact du
|
||||
@ -1470,6 +1582,69 @@ JsonReply *NymeaEnergyJsonHandler::SetTariff(const QVariantMap ¶ms)
|
||||
#endif
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetOptimizerStatus(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
||||
return createReply(arb->optimizerStatus());
|
||||
#endif
|
||||
return createReply({{"optimizerExpected", false}, {"optimizerAlive", false},
|
||||
{"activeStrategy", m_smartChargingManager && m_smartChargingManager->degradedMode()
|
||||
? "none" : "rules"},
|
||||
{"tokenConfigured", false}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetOptimizerEndpoint(const QVariantMap ¶ms)
|
||||
{
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||
QString fault;
|
||||
if (arb->setOptimizerEndpoint(params.value("endpoint").toString(), &fault))
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||
{"fault", fault}});
|
||||
}
|
||||
#else
|
||||
Q_UNUSED(params)
|
||||
#endif
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetOptimizerToken(const QVariantMap ¶ms)
|
||||
{
|
||||
// Le token ne fait que traverser : ni journalisé, ni recopié dans la réponse.
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager)) {
|
||||
QString fault;
|
||||
const QVariant token = params.value("token");
|
||||
if (token.userType() != QMetaType::QString) {
|
||||
// Jamais la valeur : seulement le fait. 7-d — le refus est dit comme une pose.
|
||||
qCWarning(dcNymeaEnergy()) << "[Optimiseur] token REFUSÉ : ce n'est pas une chaîne — token inchangé";
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||
{"fault", "token-refused"}});
|
||||
}
|
||||
if (arb->setOptimizerToken(token.toString().toUtf8(), &fault))
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||
{"fault", fault}});
|
||||
}
|
||||
#else
|
||||
Q_UNUSED(params)
|
||||
#endif
|
||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::GetPlan(const QVariantMap ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
#ifdef ETM_ARBITRATOR
|
||||
if (EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager))
|
||||
return createReply(arb->publishedPlan());
|
||||
#endif
|
||||
return createReply({{"inForce", false}});
|
||||
}
|
||||
|
||||
JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap ¶ms)
|
||||
{
|
||||
#ifdef ETM_ARBITRATOR
|
||||
|
||||
@ -143,6 +143,18 @@ public:
|
||||
//! \brief Remplace le tarif saisi, à chaud. \param params \c zones (vide = effacer), \c o:exportCtkWh.
|
||||
//! \return \c energyError ; sur refus \c fault \c TARIFF_REFUSED et \c faultParams — rien n'est modifié.
|
||||
Q_INVOKABLE JsonReply *SetTariff(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] État de la machine de repli de l'optimiseur (brief 4 T4). \param params Aucun.
|
||||
//! \return \c EnergyArbitrator::optimizerStatus() — la charge utile d'\c OptimizerStatusChanged.
|
||||
Q_INVOKABLE JsonReply *GetOptimizerStatus(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Pose l'endpoint de l'optimiseur, à chaud. \param params \c endpoint (vide = aucun).
|
||||
//! \return \c energyError ; sur refus \c fault \c invalid-endpoint — rien n'est modifié.
|
||||
Q_INVOKABLE JsonReply *SetOptimizerEndpoint(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Pose le token de l'optimiseur — ÉCRITURE SEULE (Q2). \param params \c token (vide = retrait).
|
||||
//! \return \c energyError ; sur refus \c fault \c token-refused. Le token n'est jamais rendu.
|
||||
Q_INVOKABLE JsonReply *SetOptimizerToken(const QVariantMap ¶ms);
|
||||
//! \brief [ETM] Le plan en vigueur au dernier cycle (exigence app 2). \param params Aucun.
|
||||
//! \return \c EnergyArbitrator::publishedPlan() ; \c {inForce: false} sans plan en vigueur.
|
||||
Q_INVOKABLE JsonReply *GetPlan(const QVariantMap ¶ms);
|
||||
|
||||
signals:
|
||||
void PhasePowerLimitChanged(const QVariantMap ¶ms);
|
||||
@ -159,6 +171,8 @@ signals:
|
||||
void EnergyRatiosChanged(const QVariantMap ¶ms);
|
||||
//! [ETM] État runtime d'arbitrage — sur changement significatif ET au battement périodique.
|
||||
void LoadTelemetryChanged(const QVariantMap ¶ms);
|
||||
//! [ETM] Transition de la machine de repli de l'optimiseur (§7).
|
||||
void OptimizerStatusChanged(const QVariantMap ¶ms);
|
||||
|
||||
private slots:
|
||||
void onPowerBalanceChanged();
|
||||
@ -184,6 +198,10 @@ private:
|
||||
*/
|
||||
static QVariantMap loadTelemetrySchema();
|
||||
|
||||
//! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus
|
||||
//! et \c OptimizerStatusChanged. \return Le schéma des champs.
|
||||
static QVariantMap optimizerStatusSchema();
|
||||
|
||||
//! \brief Charge utile des ratios — DÉCLARÉE UNE FOIS, servie au \c Get et au \c Changed.
|
||||
//! Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse (invariant 11).
|
||||
//! \return Le schéma des champs, prêt pour \c registerMethod et \c registerNotification.
|
||||
|
||||
@ -13,9 +13,13 @@
|
||||
#include "energysettings.h"
|
||||
#include "etm/energyarbitrator.h"
|
||||
#include "etm/scheduler/socketscheduler.h"
|
||||
#include "etm/scheduler/optimizerstatus.h"
|
||||
#include "etm/scheduler/publishedplan.h"
|
||||
#include "etm/types/decisionreason.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QLoggingCategory>
|
||||
#include <QSignalSpy>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
@ -35,16 +39,28 @@ public:
|
||||
~Journal() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
|
||||
QString texte() const { return m_texte; }
|
||||
int compte(const QString &fragment) const { return m_texte.count(fragment); }
|
||||
//! Les lignes contenant \p fragment, préfixées de leur catégorie (« JsonRpcTraffic: … »).
|
||||
QStringList lignes(const QString &fragment) const
|
||||
{
|
||||
QStringList l;
|
||||
for (const QString &x : m_lignes)
|
||||
if (x.contains(fragment))
|
||||
l << x;
|
||||
return l;
|
||||
}
|
||||
|
||||
private:
|
||||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||||
{
|
||||
if (s_courant)
|
||||
if (s_courant) {
|
||||
s_courant->m_texte += msg + QLatin1Char('\n');
|
||||
s_courant->m_lignes << QString::fromLatin1(ctx.category ? ctx.category : "?") + QStringLiteral(": ") + msg;
|
||||
}
|
||||
if (s_origine)
|
||||
s_origine(type, ctx, msg);
|
||||
}
|
||||
QString m_texte;
|
||||
QStringList m_lignes;
|
||||
static Journal *s_courant;
|
||||
static QtMessageHandler s_origine;
|
||||
};
|
||||
@ -90,6 +106,16 @@ private slots:
|
||||
void testConnectionLossDropsThePlan();
|
||||
void testThePlanOfAnOptimizerBuysNothingEco();
|
||||
void testPvIsReadFromAnyProducer();
|
||||
// Brief 4 T4 — machine de repli §7, capacités, réglage par RPC, plan publié
|
||||
void testFallbackMachineIsPure();
|
||||
void testStatusWithoutEndpoint();
|
||||
void testStatusFollowsTheFallbackTable_data();
|
||||
void testStatusFollowsTheFallbackTable();
|
||||
void testEachTransitionNotifiesOnce();
|
||||
void testEndpointIsSetByRpcAndAppliedHot();
|
||||
void testTokenIsWriteOnlyAndNeverLeaks();
|
||||
void testGetPlanPublishesThePlanInForce();
|
||||
void testUpdateLearnedIsLoggedOnceAndIgnored();
|
||||
|
||||
private:
|
||||
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
|
||||
@ -114,14 +140,73 @@ private:
|
||||
std::function<QVariantMap(const QVariantMap &)> m_repondre;
|
||||
int m_jugees = 0;
|
||||
QStringList m_codesJuges;
|
||||
QStringList m_codesEchec; //!< echecRequete() reçus (T4)
|
||||
QList<QJsonObject> m_handshakes; //!< params de chaque Handshake reçu (token)
|
||||
//! Statut publié par l'arbitre, en trois mots : « attendu|vivant|stratégie ».
|
||||
QString triplet() const;
|
||||
//! Appel RPC ; rend `params` de la réponse.
|
||||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||||
//! Plan valide sur les deux charges (ECS 1000 W, routeur 500 W), valable \p validiteS.
|
||||
std::function<QVariantMap(const QVariantMap &)> planValide(int validiteS = 1800);
|
||||
//! Optimiseur monté, plan valide reçu puis appliqué : « attendu|vivant|optimizer » à t0 + 60 s.
|
||||
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
|
||||
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
|
||||
static QStringList triplets(const QSignalSpy &spy);
|
||||
};
|
||||
|
||||
QString TestSocketScheduler::triplet() const
|
||||
{
|
||||
const QVariantMap st = m_arb->optimizerStatus();
|
||||
return QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||||
st.value("activeStrategy").toString());
|
||||
}
|
||||
|
||||
QVariantMap TestSocketScheduler::rpc(const QString &methode, const QVariantMap ¶ms)
|
||||
{
|
||||
const QVariantMap r = injectAndWait("NymeaEnergy." + methode, params).toMap();
|
||||
[&] { QCOMPARE(r.value("status").toString(), QString("success")); }();
|
||||
return r.value("params").toMap();
|
||||
}
|
||||
|
||||
std::function<QVariantMap(const QVariantMap &)> TestSocketScheduler::planValide(int validiteS)
|
||||
{
|
||||
return [this, validiteS](const QVariantMap &req) {
|
||||
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}, validiteS);
|
||||
};
|
||||
}
|
||||
|
||||
void TestSocketScheduler::amenerAOptimiseur(const QDateTime &t0, int validiteS)
|
||||
{
|
||||
m_repondre = planValide(validiteS);
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||||
}
|
||||
|
||||
QStringList TestSocketScheduler::triplets(const QSignalSpy &spy)
|
||||
{
|
||||
QStringList l;
|
||||
for (const QList<QVariant> &args : spy) {
|
||||
const QVariantMap st = args.first().toMap();
|
||||
l << QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||||
st.value("activeStrategy").toString());
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport transport)
|
||||
{
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-socketscheduler.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
// T4 : chaque banc part sans endpoint ni token — l'arbitre les lit à sa construction, et une
|
||||
// fonction précédente du même processus a pu les poser.
|
||||
EnergySettings().remove("Optimizer/endpoint");
|
||||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
@ -155,6 +240,8 @@ void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport t
|
||||
m_repondre = nullptr;
|
||||
m_jugees = 0;
|
||||
m_codesJuges.clear();
|
||||
m_codesEchec.clear();
|
||||
m_handshakes.clear();
|
||||
if (!avecOptimiseur)
|
||||
return;
|
||||
|
||||
@ -167,6 +254,7 @@ void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport t
|
||||
m_serveur->responder = [this](const QJsonObject &m) {
|
||||
const qint64 id = m.value("id").toInteger();
|
||||
if (m.value("method").toString() == QLatin1String("Handshake")) {
|
||||
m_handshakes.append(m.value("params").toObject());
|
||||
m_serveur->replyResult(id, m_handshake);
|
||||
return true;
|
||||
}
|
||||
@ -175,7 +263,10 @@ void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport t
|
||||
m_requetes.append(params);
|
||||
if (m_repondre) {
|
||||
const QVariantMap r = m_repondre(params);
|
||||
if (!r.isEmpty())
|
||||
if (r.contains("__erreur")) // T4 : une erreur JSON-RPC, échec de la requête
|
||||
m_serveur->sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id},
|
||||
{"error", QJsonObject{{"code", -32000}, {"message", "panne"}}}});
|
||||
else if (!r.isEmpty())
|
||||
m_serveur->replyResult(id, QJsonObject::fromVariantMap(r));
|
||||
}
|
||||
return true;
|
||||
@ -190,6 +281,10 @@ void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport t
|
||||
++m_jugees;
|
||||
m_codesJuges.append(code);
|
||||
});
|
||||
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) {
|
||||
++m_jugees; // une requête en échec est aussi « jugée » : le cycle n'attend pas plus
|
||||
m_codesEchec.append(code);
|
||||
});
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||||
}
|
||||
|
||||
@ -731,5 +826,534 @@ void TestSocketScheduler::testPvIsReadFromAnyProducer()
|
||||
QCOMPARE(pv.value("currentW").toDouble(), 2400.0);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testFallbackMachineIsPure()
|
||||
{
|
||||
using E = OptimizerStatus::Vivacite::Evenement;
|
||||
OptimizerStatus::Vivacite v;
|
||||
const QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
// Faux tant qu'aucun plan valide n'a été reçu : un optimiseur jamais entendu n'est pas vivant.
|
||||
QVERIFY(!v.vivant());
|
||||
QVERIFY(!v.noter(E::Echec, "timeout"));
|
||||
QCOMPARE(v.echecsConsecutifs(), 1);
|
||||
QVERIFY(v.noter(E::PlanValide, QString(), t));
|
||||
QVERIFY(v.vivant());
|
||||
QCOMPARE(v.echecsConsecutifs(), 0);
|
||||
QCOMPARE(v.dernierPlanA(), t);
|
||||
QCOMPARE(v.dernierCodeErreur(), QString("timeout")); // le dernier code reste lisible
|
||||
// Abstention : vivant inchangé, et la série d'échecs est ROMPUE (« consécutifs »).
|
||||
QVERIFY(!v.noter(E::Echec, "rpc-error"));
|
||||
QVERIFY(v.vivant());
|
||||
QVERIFY(!v.noter(E::Abstention));
|
||||
QCOMPARE(v.echecsConsecutifs(), 0);
|
||||
QVERIFY(!v.noter(E::Echec, "rpc-error"));
|
||||
QVERIFY(v.vivant()); // un échec isolé ne tue pas
|
||||
QVERIFY(v.noter(E::Echec, "incomplete-slot")); // le second, oui (E17 : par requête)
|
||||
QVERIFY(!v.vivant());
|
||||
QCOMPARE(v.echecsConsecutifs(), 2);
|
||||
QCOMPARE(v.dernierCodeErreur(), QString("incomplete-slot"));
|
||||
// Seul un plan valide rend la vie : une abstention dit qu'il répond, pas qu'il planifie.
|
||||
QVERIFY(!v.noter(E::Abstention));
|
||||
QVERIFY(!v.vivant());
|
||||
QVERIFY(v.noter(E::PlanValide, QString(), t.addSecs(60)));
|
||||
// Socket fermé : mort tout de suite, sans attendre deux requêtes.
|
||||
QVERIFY(v.noter(E::ConnexionPerdue, "connection-lost"));
|
||||
QVERIFY(!v.vivant());
|
||||
QCOMPARE(v.dernierCodeErreur(), QString("connection-lost"));
|
||||
|
||||
// La colonne « stratégie active » : L2 d'abord, puis la vivacité.
|
||||
QCOMPARE(OptimizerStatus::strategieActive(true, true, "optimizer"), QString("none"));
|
||||
QCOMPARE(OptimizerStatus::strategieActive(true, false, "rules"), QString("none"));
|
||||
QCOMPARE(OptimizerStatus::strategieActive(false, false, "optimizer"), QString("rules"));
|
||||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "optimizer"), QString("optimizer"));
|
||||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "rules"), QString("rules"));
|
||||
QCOMPARE(OptimizerStatus::strategieActive(false, true, "none"), QString("none")); // L2 levé, pas encore replanifié
|
||||
|
||||
// Ce qu'un optimiseur écrit librement n'atteint pas l'app (mode installateur).
|
||||
const QVariantMap cap = OptimizerStatus::capacitesPubliees(QVariantMap{
|
||||
{"maxHorizonH", 48}, {"resolutionS", "3600"}, {"kinds", QVariantList{"setpoint", "fusee", 3}},
|
||||
{"usesForecast", QVariantList{"pv", "astrologie"}}, {"reasonCodes", true}, {"libre", kTexteLibre}});
|
||||
QCOMPARE(cap, (QVariantMap{{"maxHorizonH", 48.0}, {"kinds", QVariantList{"setpoint"}},
|
||||
{"usesForecast", QVariantList{"pv"}}, {"reasonCodes", true}}));
|
||||
QCOMPARE(OptimizerStatus::identite(QString("etm\x1b" "optimizer\n v2"), 64), QString("etm optimizer v2"));
|
||||
QCOMPARE(OptimizerStatus::identite(QString(100, 'x'), 64).size(), 64);
|
||||
QVERIFY(OptimizerStatus::identite(QString("\x07\x1b"), 64).isEmpty());
|
||||
|
||||
QCOMPARE(PublishedPlan::attenduPublie(QVariantMap{{"gridImportW", 0}, {"gridExportW", 350.5},
|
||||
{"note", "texte"}, {"batterySocPercent", 40}}),
|
||||
(QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.5}}));
|
||||
QCOMPARE(PublishedPlan::diagnosticsPublies(QVariantMap{
|
||||
{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
|
||||
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}}),
|
||||
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
|
||||
QVERIFY(PublishedPlan::diagnosticsPublies(QVariantMap{
|
||||
{"status", "presque"}, {"inputsUsed", QVariantMap{{"pv", kTexteLibre}}}}).isEmpty());
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testStatusWithoutEndpoint()
|
||||
{
|
||||
// §7, ligne « optimizerExpected = false » : rien n'est attendu, rien n'est vivant, le
|
||||
// rule-based commande. Ce qui n'a pas de valeur est ABSENT, jamais 0.
|
||||
monter(false);
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
Journal j;
|
||||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}})); // aucun cycle encore
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(st, (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
|
||||
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
|
||||
QCOMPARE(spy.count(), 0); // un cycle n'est pas une transition
|
||||
|
||||
// Le plan en vigueur : celui, à un créneau, du rule-based. Pas de fallbackCode : rien n'a été
|
||||
// refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ».
|
||||
const QVariantMap p = rpc("GetPlan");
|
||||
QCOMPARE(p.value("inForce").toBool(), true);
|
||||
QCOMPARE(p.value("strategy").toString(), QString("rule-based"));
|
||||
QCOMPARE(p.value("computedAt").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
|
||||
QVERIFY(p.contains("validUntil"));
|
||||
QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics"));
|
||||
QCOMPARE(p.value("slots").toList().count(), 1);
|
||||
QStringList ids;
|
||||
for (const QVariant &a : p.value("slots").toList().first().toMap().value("actions").toList()) {
|
||||
ids << a.toMap().value("loadId").toString();
|
||||
QVERIFY(!a.toMap().value("reasonCode").toString().isEmpty());
|
||||
QVERIFY(!a.toMap().contains("reason"));
|
||||
}
|
||||
QVERIFY2(ids.contains("ecs") && ids.contains(m_var), qUtf8Printable(ids.join(',')));
|
||||
|
||||
// L2 l'emporte sur tout, même sans optimiseur : `none`, UNE notification, plus de plan.
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||||
QCOMPARE(triplet(), QString("-|mort|none"));
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(260));
|
||||
QCOMPARE(triplets(spy), QStringList{"-|mort|none"});
|
||||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
|
||||
// Sortie de L2 : la planification reprend au CYCLE, et la stratégie avec elle — d'ici là,
|
||||
// rien n'a été planifié, `none` tient.
|
||||
m_arb->recordMeterUpdate(t0.addSecs(290));
|
||||
QVERIFY(!m_arb->degradedMode());
|
||||
QCOMPARE(triplet(), QString("-|mort|none"));
|
||||
cycle(t0.addSecs(300), -3000);
|
||||
QCOMPARE(triplets(spy), (QStringList{"-|mort|none", "-|mort|rules"}));
|
||||
QCOMPARE(rpc("GetPlan").value("strategy").toString(), QString("rule-based"));
|
||||
// La notification JSON-RPC porte la charge utile du Get, une fois par transition.
|
||||
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
|
||||
QCOMPARE(notifs.count(), 2);
|
||||
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
|
||||
QCOMPARE(QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap()),
|
||||
QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
|
||||
// Sans endpoint, le moteur ne dit rien de l'optimiseur.
|
||||
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testStatusFollowsTheFallbackTable_data()
|
||||
{
|
||||
QTest::addColumn<QString>("cas");
|
||||
QTest::addColumn<QString>("attendu"); // triplet après l'événement
|
||||
QTest::addColumn<int>("echecs"); // consecutiveFailures
|
||||
QTest::addColumn<QString>("code"); // lastErrorCode, vide = absent
|
||||
QTest::newRow("plan valide") << "valide" << "attendu|vivant|optimizer" << 0 << "";
|
||||
QTest::newRow("abstention") << "abstention" << "attendu|vivant|rules" << 0 << "";
|
||||
QTest::newRow("plan expiré") << "expire" << "attendu|vivant|rules" << 0 << "";
|
||||
QTest::newRow("un échec") << "un-echec" << "attendu|vivant|optimizer" << 1 << "rpc-error";
|
||||
QTest::newRow("deux échecs") << "deux-echecs" << "attendu|mort|rules" << 2 << "rpc-error";
|
||||
QTest::newRow("plan invalide ×2") << "invalide" << "attendu|mort|rules" << 2 << "incomplete-slot";
|
||||
QTest::newRow("requête remplacée") << "remplacee" << "attendu|vivant|optimizer" << 0 << "";
|
||||
QTest::newRow("socket fermé") << "ferme" << "attendu|mort|rules" << 0 << "connection-lost";
|
||||
QTest::newRow("mode dégradé L2") << "l2" << "attendu|vivant|none" << 0 << "";
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testStatusFollowsTheFallbackTable()
|
||||
{
|
||||
QFETCH(QString, cas);
|
||||
QFETCH(QString, attendu);
|
||||
QFETCH(int, echecs);
|
||||
QFETCH(QString, code);
|
||||
monter();
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
// Plan expiré : chaque plan vaut 90 s, et l'optimiseur se tait ensuite.
|
||||
amenerAOptimiseur(t0, cas == "expire" ? 90 : 1800);
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
|
||||
if (cas == "abstention") {
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
|
||||
} else if (cas == "expire") {
|
||||
m_repondre = nullptr;
|
||||
cycle(t0.addSecs(200), -3000); // le plan reçu à t0 + 60 valait jusqu'à t0 + 150
|
||||
QCOMPARE(s->dernierCodeDeRepli(), QString("expired"));
|
||||
} else if (cas == "un-echec" || cas == "deux-echecs") {
|
||||
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
QVERIFY(s->optimiseurAuDernierCycle()); // un échec isolé : le plan en cache tient
|
||||
if (cas == "deux-echecs") {
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
QVERIFY(!s->aUnPlanEnCache()); // mort : le cache n'est plus cru
|
||||
}
|
||||
} else if (cas == "invalide") {
|
||||
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); // un seul plan invalide
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
} else if (cas == "remplacee") {
|
||||
// Un optimiseur qui ne répond pas avant le cycle suivant : chaque requête est REMPLACÉE
|
||||
// par la suivante. Ce n'est pas un échec (T2) — seul le délai de 10 s en ferait un.
|
||||
m_repondre = nullptr;
|
||||
cycle(t0.addSecs(120), -3000);
|
||||
cycle(t0.addSecs(180), -3000);
|
||||
cycle(t0.addSecs(240), -3000);
|
||||
QVERIFY(s->optimiseurAuDernierCycle());
|
||||
} else if (cas == "ferme") {
|
||||
m_serveur->responder = nullptr;
|
||||
m_serveur->abortClient();
|
||||
QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; }));
|
||||
QVERIFY(!s->aUnPlanEnCache());
|
||||
} else if (cas == "l2") {
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(200));
|
||||
QVERIFY(m_arb->degradedMode());
|
||||
}
|
||||
|
||||
QCOMPARE(triplet(), attendu);
|
||||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||||
QCOMPARE(QJsonObject::fromVariantMap(st), QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
|
||||
QCOMPARE(st.value("consecutiveFailures").toInt(), echecs);
|
||||
QCOMPARE(st.value("lastErrorCode").toString(), code);
|
||||
QCOMPARE(st.contains("lastErrorCode"), !code.isEmpty()); // inconnu = absent, jamais ""
|
||||
// Identité du handshake, en mode installateur ; lastPlanAt = cycle de la requête du plan.
|
||||
QCOMPARE(st.value("name").toString(), m_handshake.value("name").toString());
|
||||
QCOMPARE(st.value("protocolVersion").toString(), m_handshake.value("protocolVersion").toString());
|
||||
QVERIFY(st.value("lastPlanAt").toString().endsWith("Z"));
|
||||
QCOMPARE(st.value("tokenConfigured").toBool(), false);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testEachTransitionNotifiesOnce()
|
||||
{
|
||||
monter();
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto t = [&](int s) { return t0.addSecs(s); };
|
||||
const auto abstention = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
|
||||
const auto erreur = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
|
||||
|
||||
// Vivant : le plan valide rend la vie à sa RÉCEPTION, la stratégie suit au cycle qui l'applique.
|
||||
m_repondre = planValide();
|
||||
cycle(t(0), -3000);
|
||||
cycle(t(60), -3000);
|
||||
// Abstention RÉPÉTÉE : une seule transition vers rules.
|
||||
m_repondre = abstention;
|
||||
for (int s = 120; s <= 300; s += 60)
|
||||
cycle(t(s), -3000);
|
||||
m_repondre = planValide();
|
||||
cycle(t(360), -3000);
|
||||
cycle(t(420), -3000);
|
||||
// Vivant → mort (deux requêtes en échec) → vivant.
|
||||
m_repondre = erreur;
|
||||
cycle(t(480), -3000);
|
||||
cycle(t(540), -3000);
|
||||
cycle(t(600), -3000); // un troisième échec : déjà mort, rien à dire
|
||||
m_repondre = planValide(90);
|
||||
cycle(t(660), -3000);
|
||||
cycle(t(720), -3000);
|
||||
// Plan expiré, puis nouveau plan.
|
||||
m_repondre = nullptr;
|
||||
cycle(t(840), -3000);
|
||||
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("expired"));
|
||||
m_repondre = planValide();
|
||||
cycle(t(900), -3000);
|
||||
cycle(t(960), -3000);
|
||||
// L2 pendant l'optimiseur, puis sortie : vivant inchangé, le plan en cache reprend.
|
||||
m_arb->evaluateMeterFreshness(t(1160));
|
||||
m_arb->evaluateMeterFreshness(t(1200));
|
||||
cycle(t(1220), -3000);
|
||||
|
||||
const QStringList attendu{
|
||||
"attendu|vivant|rules", // plan valide reçu, pas encore appliqué
|
||||
"attendu|vivant|optimizer",
|
||||
"attendu|vivant|rules", // abstention ×4 : une fois
|
||||
"attendu|vivant|optimizer",
|
||||
"attendu|mort|rules", // second échec consécutif
|
||||
"attendu|vivant|rules", // premier plan valide
|
||||
"attendu|vivant|optimizer",
|
||||
"attendu|vivant|rules", // plan expiré
|
||||
"attendu|vivant|optimizer", // nouveau plan
|
||||
"attendu|vivant|none", // L2, prioritaire
|
||||
"attendu|vivant|optimizer", // sortie de L2, premier cycle planifié
|
||||
};
|
||||
QCOMPARE(triplets(spy), attendu);
|
||||
// Le transport : une notification JSON-RPC par transition, charge utile du Get.
|
||||
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
|
||||
QCOMPARE(notifs.count(), attendu.count());
|
||||
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
|
||||
// `lastPlanAt` a avancé depuis (un plan de plus, sans transition) : c'est voulu, la
|
||||
// notification ne suit que les transitions, le Get rend l'état du moment.
|
||||
QJsonObject notifie = QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap());
|
||||
QJsonObject lu = QJsonObject::fromVariantMap(m_arb->optimizerStatus());
|
||||
QVERIFY(notifie.contains("lastPlanAt") && lu.value("lastPlanAt") != notifie.value("lastPlanAt"));
|
||||
notifie.remove("lastPlanAt");
|
||||
lu.remove("lastPlanAt");
|
||||
QCOMPARE(notifie, lu);
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testEndpointIsSetByRpcAndAppliedHot()
|
||||
{
|
||||
monter(false);
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||||
QVERIFY(m_dir.isValid());
|
||||
FakeOptimizer unixSrv(FakeOptimizer::Unix, m_dir.path());
|
||||
FakeOptimizer tcpSrv(FakeOptimizer::Tcp, m_dir.path());
|
||||
QVERIFY(unixSrv.listen() && tcpSrv.listen());
|
||||
const QJsonObject hs = handshakeResult();
|
||||
for (FakeOptimizer *f : {&unixSrv, &tcpSrv})
|
||||
f->responder = [f, hs](const QJsonObject &m) {
|
||||
if (m.value("method").toString() != QLatin1String("Handshake"))
|
||||
return false;
|
||||
f->replyResult(m.value("id").toInteger(), hs);
|
||||
return true;
|
||||
};
|
||||
auto fixer = [this](const QString &e) { return rpc("SetOptimizerEndpoint", {{"endpoint", e}}); };
|
||||
auto reglage = [] { return EnergySettings().value("Optimizer/endpoint"); };
|
||||
|
||||
// Illisible : refusé, DIT, et rien ne change.
|
||||
Journal j;
|
||||
QVariantMap r = fixer("http://optimiseur:80");
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||||
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
|
||||
QVERIFY(!reglage().isValid());
|
||||
QVERIFY(!m_arb->socketScheduler());
|
||||
QCOMPARE(j.compte("endpoint REFUSÉ"), 1);
|
||||
QCOMPARE(spy.count(), 0);
|
||||
|
||||
// unix:// : persisté, appliqué à chaud, le client se connecte.
|
||||
r = fixer(unixSrv.endpoint());
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
QCOMPARE(reglage().toString(), unixSrv.endpoint());
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
QVERIFY(s);
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||||
QCOMPARE(triplet(), QString("attendu|mort|rules"));
|
||||
QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"});
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), hs.value("name").toString());
|
||||
|
||||
// Un endpoint illisible pendant qu'un autre est posé : refusé, l'autre reste.
|
||||
r = fixer("tcp://sans-port");
|
||||
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
|
||||
QCOMPARE(m_arb->socketScheduler(), s);
|
||||
QCOMPARE(reglage().toString(), unixSrv.endpoint());
|
||||
|
||||
// tcp:// : la stratégie est reconstruite vers le nouvel endpoint.
|
||||
r = fixer(tcpSrv.endpoint());
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
s = m_arb->socketScheduler();
|
||||
QVERIFY(s);
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||||
QCOMPARE(m_arb->findChildren<OptimizerClient *>().count(), 1);
|
||||
QCOMPARE(tcpSrv.connections, 1);
|
||||
|
||||
// Vide : retiré du réglage, stratégie et client détruits, plus rien n'est attendu.
|
||||
r = fixer("");
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
QVERIFY(!reglage().isValid());
|
||||
QVERIFY(!m_arb->socketScheduler());
|
||||
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
|
||||
QCOMPARE(rpc("GetOptimizerStatus"), (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
|
||||
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
|
||||
QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "-|mort|rules"}));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testTokenIsWriteOnlyAndNeverLeaks()
|
||||
{
|
||||
monter();
|
||||
m_repondre = planValide();
|
||||
const QString secret = QStringLiteral("T4-secret-qui-ne-sort-jamais-0123456789abcdef");
|
||||
const QString chemin = OptimizerClient::defaultTokenPath();
|
||||
QFile::remove(chemin);
|
||||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
// TOUT le journal, tous niveaux, toutes catégories — nymea compris.
|
||||
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
|
||||
Journal j;
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
|
||||
QVariantMap r = rpc("SetOptimizerToken", {{"token", secret}});
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
QCOMPARE(r.keys(), QStringList{"energyError"});
|
||||
QFile f(chemin);
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
|
||||
f.close();
|
||||
const QFileDevice::Permissions autres = QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::WriteGroup
|
||||
| QFileDevice::ExeGroup | QFileDevice::ReadOther | QFileDevice::WriteOther | QFileDevice::ExeOther;
|
||||
QVERIFY(QFile::permissions(chemin) & QFileDevice::ReadOwner);
|
||||
QVERIFY(QFile::permissions(chemin) & QFileDevice::WriteOwner);
|
||||
QCOMPARE(QFile::permissions(chemin) & autres, QFileDevice::Permissions()); // 0600
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), true);
|
||||
QVERIFY(!EnergySettings().allKeys().join(' ').contains("token", Qt::CaseInsensitive));
|
||||
|
||||
// Le secret TRANSITE bien : à la reconnexion suivante, le handshake le porte.
|
||||
const int handshakes = m_handshakes.count();
|
||||
m_serveur->abortClient();
|
||||
QVERIFY(waitFor([this, handshakes] { return m_handshakes.count() > handshakes; }, 8000));
|
||||
QCOMPARE(m_handshakes.last().value("token").toString(), secret);
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
|
||||
cycle(t0, -3000);
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
QVERIFY(s->optimiseurAuDernierCycle());
|
||||
rpc("GetPlan");
|
||||
rpc("GetOptimizerStatus");
|
||||
|
||||
// Refus : pas une chaîne, un blanc — rien ne change, le secret reste.
|
||||
r = injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", 31415926}}).toMap().value("params").toMap();
|
||||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||||
r = rpc("SetOptimizerToken", {{"token", "avec blanc"}});
|
||||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
|
||||
f.close();
|
||||
|
||||
// Retrait : fichier supprimé, tokenConfigured faux.
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", ""}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
QVERIFY(!QFile::exists(chemin));
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
|
||||
QVERIFY(j.texte().contains("token d'optimiseur posé") && j.texte().contains("token d'optimiseur retiré"));
|
||||
QVERIFY(j.texte().contains("token REFUSÉ"));
|
||||
|
||||
// Aucune RÉPONSE ni notification ne contient le secret.
|
||||
for (const QList<QVariant> &p : paquets)
|
||||
QVERIFY2(!p.last().toByteArray().contains(secret.toUtf8()), p.last().toByteArray().left(200).constData());
|
||||
QVERIFY(paquets.count() > 5); // le flux coulait
|
||||
// Aucun journal du MOTEUR ne le contient. Il n'apparaît que dans la trace BRUTE des données
|
||||
// entrantes de nymea (« Incoming data: », catégorie JsonRpcTraffic, niveau debug, éteinte par
|
||||
// défaut) — hors de portée du plugin, comme pour le token d'authentification nymea lui-même.
|
||||
auto horsTrafic = [&j](const QString &fragment) {
|
||||
QStringList l;
|
||||
for (const QString &x : j.lignes(fragment))
|
||||
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
|
||||
l << x.left(200);
|
||||
return l;
|
||||
};
|
||||
QVERIFY2(horsTrafic(secret).isEmpty(), qUtf8Printable(horsTrafic(secret).join('\n')));
|
||||
QVERIFY2(horsTrafic("31415926").isEmpty(), qUtf8Printable(horsTrafic("31415926").join('\n')));
|
||||
QVERIFY2(!j.lignes(secret).isEmpty(),
|
||||
"la trace JsonRpcTraffic de nymea ne recopie plus les données entrantes : retirer la mise "
|
||||
"en garde d'INTERFACE.md (SetOptimizerToken)");
|
||||
|
||||
// Ce que nymea fait d'une clé EN TROP : le validateur refuse l'appel avant le handler et
|
||||
// recopie les paramètres dans un AVERTISSEMENT (« Call params: », catégorie JsonRpc). Épinglé
|
||||
// ici parce que c'est la seule fuite possible au journal par défaut, et qu'elle est hors du
|
||||
// plugin : si ce test casse, nymea a cessé de le faire — retirer la mise en garde d'INTERFACE.md.
|
||||
const QString autre = QStringLiteral("T4-autre-secret-cle-en-trop");
|
||||
injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", autre}, {"cle", 1}});
|
||||
QVERIFY(!j.lignes(autre).filter(QRegularExpression("^JsonRpc: ")).isEmpty());
|
||||
QVERIFY(!QFile::exists(chemin)); // l'appel n'est jamais arrivé au handler
|
||||
|
||||
QLoggingCategory::setFilterRules(QStringLiteral(
|
||||
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
|
||||
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testGetPlanPublishesThePlanInForce()
|
||||
{
|
||||
monter();
|
||||
m_repondre = [this](const QVariantMap &req) {
|
||||
const QDateTime ts = QDateTime::fromString(req.value("timestamp").toString(), Qt::ISODate);
|
||||
QVariantMap p = plan(req, {action("ecs", {{"powerW", 2500}, {"reasonCode", "SURPLUS_SETPOINT"},
|
||||
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"note", "texte libre"}}}}),
|
||||
action(m_var, {{"powerW", 1800}})});
|
||||
QVariantList creneaux = p.value("slots").toList();
|
||||
QVariantMap c0 = creneaux.first().toMap();
|
||||
c0.insert("expected", QVariantMap{{"gridImportW", 0}, {"gridExportW", 350}, {"commentaire", kTexteLibre}});
|
||||
creneaux[0] = c0;
|
||||
creneaux.append(QVariantMap{{"from", ts.addSecs(900).toString(Qt::ISODate)},
|
||||
{"to", ts.addSecs(1800).toString(Qt::ISODate)},
|
||||
{"actions", QVariantList{action("ecs", {{"powerW", 0}}),
|
||||
action(m_var, {{"powerW", 3000}})}}});
|
||||
p.insert("slots", creneaux);
|
||||
p.insert("computedAt", ts.toString(Qt::ISODate));
|
||||
p.insert("diagnostics", QVariantMap{{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
|
||||
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}});
|
||||
return p;
|
||||
};
|
||||
auto parId = [](const QVariantMap &creneau) {
|
||||
QHash<QString, QVariantMap> h;
|
||||
for (const QVariant &a : creneau.value("actions").toList())
|
||||
h.insert(a.toMap().value("loadId").toString(), a.toMap());
|
||||
return h;
|
||||
};
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// Cycle 1 : un optimiseur est configuré mais n'a pas commandé — « pas de plan ce cycle ».
|
||||
cycle(t0, -3000);
|
||||
QVariantMap p = rpc("GetPlan");
|
||||
QCOMPARE(p.value("strategy").toString(), QString("rule-based"));
|
||||
QCOMPARE(p.value("fallbackCode").toString(), QString("no-plan"));
|
||||
QCOMPARE(p.value("slots").toList().count(), 1);
|
||||
|
||||
// Cycle 2 : le plan de l'optimiseur EN VIGUEUR.
|
||||
cycle(t0.addSecs(60), -3000);
|
||||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||||
p = rpc("GetPlan");
|
||||
QCOMPARE(p.value("strategy").toString(), QString("optimizer"));
|
||||
QVERIFY(!p.contains("fallbackCode"));
|
||||
QCOMPARE(p.value("computedAt").toString(), t0.toUTC().toString(Qt::ISODate));
|
||||
QCOMPARE(p.value("validUntil").toString(), t0.addSecs(1800).toUTC().toString(Qt::ISODate));
|
||||
QCOMPARE(p.value("diagnostics").toMap(),
|
||||
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
|
||||
const QVariantList creneaux = p.value("slots").toList();
|
||||
QCOMPARE(creneaux.count(), 2);
|
||||
const QVariantMap c0 = creneaux.at(0).toMap(), c1 = creneaux.at(1).toMap();
|
||||
QCOMPARE(c0.value("from").toString(), t0.addSecs(-60).toUTC().toString(Qt::ISODate));
|
||||
QCOMPARE(c0.value("expected").toMap(), (QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.0}}));
|
||||
QVERIFY(!c1.contains("expected"));
|
||||
// Créneau courant : la puissance APPLIQUÉE (un relais de 1000 W, 2500 demandés) ;
|
||||
// créneau suivant : la puissance DEMANDÉE.
|
||||
QCOMPARE(qRound(parId(c0).value("ecs").value("powerW").toDouble()), 1000);
|
||||
QCOMPARE(qRound(parId(c0).value(m_var).value("powerW").toDouble()), 1800);
|
||||
QCOMPARE(qRound(parId(c1).value("ecs").value("powerW").toDouble()), 0);
|
||||
QCOMPARE(qRound(parId(c1).value(m_var).value("powerW").toDouble()), 3000);
|
||||
// Motifs : codes et paramètres FILTRÉS seulement (invariant 12).
|
||||
QCOMPARE(parId(c0).value("ecs").value("reasonCode").toString(), QString(DecisionCode::SurplusSetpoint));
|
||||
QCOMPARE(parId(c0).value("ecs").value("reasonParams").toMap(), (QVariantMap{{"budgetW", 3000}}));
|
||||
QCOMPARE(parId(c0).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
|
||||
QCOMPARE(parId(c1).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
|
||||
const QByteArray json = QJsonDocument::fromVariant(p).toJson();
|
||||
for (const char *interdit : {"LIBRE", "texte libre", "highs", "plan-test", "\"faux\"", "\"reason\"", "commentaire"})
|
||||
QVERIFY2(!json.contains(interdit), interdit);
|
||||
|
||||
// L2 : aucun plan en vigueur.
|
||||
m_arb->evaluateMeterFreshness(t0.addSecs(260));
|
||||
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
|
||||
}
|
||||
|
||||
void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
|
||||
{
|
||||
// §8 : reçu, journalisé UNE fois, ignoré — la persistance est un lot ultérieur.
|
||||
monter();
|
||||
amenerAOptimiseur(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
|
||||
const QVariantMap avant = m_arb->optimizerStatus();
|
||||
Journal j;
|
||||
const QJsonObject n{{"jsonrpc", "2.0"}, {"method", "UpdateLearned"},
|
||||
{"params", QJsonObject{{"learned", QJsonObject{{"ecsWhPerDay", 4321}}},
|
||||
{"confidence", 0.5}, {"note", kTexteLibre}}}};
|
||||
m_serveur->sendJson(n);
|
||||
m_serveur->sendJson(n);
|
||||
settle(200);
|
||||
QCOMPARE(j.compte("UpdateLearned reçu et IGNORÉ"), 1);
|
||||
QVERIFY(!j.texte().contains(kTexteLibre) && !j.texte().contains("4321")); // le contenu n'est pas écrit
|
||||
QCOMPARE(m_arb->socketScheduler()->client()->state(), OptimizerClient::Ready);
|
||||
QCOMPARE(m_arb->optimizerStatus(), avant);
|
||||
// Ignoré : rien n'en revient dans la requête suivante.
|
||||
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 2, 0)), -3000);
|
||||
const QByteArray req = QJsonDocument::fromVariant(m_requetes.last()).toJson();
|
||||
QVERIFY(!req.contains("4321") && !req.contains("learned"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestSocketScheduler)
|
||||
#include "testsocketscheduler.moc"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user