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:
Patrick Schurig 2026-10-17 09:34:39 +02:00
parent 696cb001f1
commit c1ce67942c
12 changed files with 1544 additions and 13 deletions

View File

@ -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 &currentDateTime)
// 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

View File

@ -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*.

View File

@ -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 \

View 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);
}
}

View 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);
}

View 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;
}
}

View 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);
}

View File

@ -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)

View File

@ -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
};

View File

@ -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 &params)
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 &params)
#endif
}
JsonReply *NymeaEnergyJsonHandler::GetOptimizerStatus(const QVariantMap &params)
{
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 &params)
{
#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 &params)
{
// 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 &params)
{
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 &params)
{
#ifdef ETM_ARBITRATOR

View File

@ -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 &params);
//! \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 &params);
//! \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 &params);
//! \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 &params);
//! \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 &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
@ -159,6 +171,8 @@ signals:
void EnergyRatiosChanged(const QVariantMap &params);
//! [ETM] État runtime d'arbitrage — sur changement significatif ET au battement périodique.
void LoadTelemetryChanged(const QVariantMap &params);
//! [ETM] Transition de la machine de repli de l'optimiseur (§7).
void OptimizerStatusChanged(const QVariantMap &params);
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.

View File

@ -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 &params = 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 &params)
{
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"