diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 7405a8a..ee9cec5 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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 #include #include +#include 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 diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index ea81574..f80640a 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 m_adapters; //!< loadId (ThingId string) → EvAdapter*. diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 35e4060..ceb5296 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/scheduler/optimizerstatus.cpp b/energyplugin/etm/scheduler/optimizerstatus.cpp new file mode 100644 index 0000000..d91769e --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerstatus.cpp @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "optimizerstatus.h" + +#include +#include + +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); +} + +} diff --git a/energyplugin/etm/scheduler/optimizerstatus.h b/energyplugin/etm/scheduler/optimizerstatus.h new file mode 100644 index 0000000..d865670 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerstatus.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include + +/*! + * \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); + +} diff --git a/energyplugin/etm/scheduler/publishedplan.cpp b/energyplugin/etm/scheduler/publishedplan.cpp new file mode 100644 index 0000000..cbd8b58 --- /dev/null +++ b/energyplugin/etm/scheduler/publishedplan.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "publishedplan.h" + +#include +#include + +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 &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 &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; +} + +} diff --git a/energyplugin/etm/scheduler/publishedplan.h b/energyplugin/etm/scheduler/publishedplan.h new file mode 100644 index 0000000..c8f6c87 --- /dev/null +++ b/energyplugin/etm/scheduler/publishedplan.h @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#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 &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); + +} diff --git a/energyplugin/etm/scheduler/socketscheduler.cpp b/energyplugin/etm/scheduler/socketscheduler.cpp index afc84be..d23bbe2 100644 --- a/energyplugin/etm/scheduler/socketscheduler.cpp +++ b/energyplugin/etm/scheduler/socketscheduler.cpp @@ -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) diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h index 06b0774..8c08f52 100644 --- a/energyplugin/etm/scheduler/socketscheduler.h +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -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 m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé QHash m_ecretageVu; // loadId → dernier écrêtage annoncé QSet 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 }; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index d789051..0bc0ef3 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -548,6 +548,80 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("energyError", enumRef()); 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()); + 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()); + 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(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(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(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(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(m_smartChargingManager)) + return createReply(arb->publishedPlan()); +#endif + return createReply({{"inForce", false}}); +} + JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap ¶ms) { #ifdef ETM_ARBITRATOR diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index f62b032..88da143 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -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. diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index d8d6fed..83d2094 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -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 #include +#include #include #include @@ -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 m_repondre; int m_jugees = 0; QStringList m_codesJuges; + QStringList m_codesEchec; //!< echecRequete() reçus (T4) + QList 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 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 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 &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(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("cas"); + QTest::addColumn("attendu"); // triplet après l'événement + QTest::addColumn("echecs"); // consecutiveFailures + QTest::addColumn("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().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().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 &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 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"