// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync // // Brief 4 T3 — la stratégie optimiseur dans l'arbitre RÉEL : endpoint lu dans energy.conf, faux // optimiseur du processus, cycles injectés (temps simulé pour l'arbitre ; seules les E/S de // socket passent par la boucle d'événements). Chaque test monte son banc : un compteur, un ECS // à un relais de 1000 W (rang 1), un routeur etmvariableload de 3000 W (rang 2). #include "simulationbase.h" #include "experience/energymanagermock.h" #include "../common/etm-powersync-fakeoptimizer.h" #include "../common/etm-powersync-contrat.h" #include "energysettings.h" #include "etm/energyarbitrator.h" #include "etm/scheduler/socketscheduler.h" #include "etm/scheduler/optimizerstatus.h" #include "etm/scheduler/publishedplan.h" #include "etm/scheduler/optimizertrace.h" #include "etm/scheduler/optimizerprotocol.h" #include "etm/types/decisionreason.h" #include #include #include #include #include #include #include #include #include using namespace nymeaserver; namespace { const QString kTexteLibre = QStringLiteral("Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"); //! Capture du journal le temps d'une portée (même principe que la suite simulation). class Journal { public: Journal() { s_courant = this; s_origine = qInstallMessageHandler(&Journal::collecte); } ~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) { 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; }; Journal *Journal::s_courant = nullptr; QtMessageHandler Journal::s_origine = nullptr; QHash telemetrieParId(const QVariantMap &snap) { QHash h; for (const QVariant &v : snap.value("loads").toList()) h.insert(v.toMap().value("loadId").toString(), v.toMap()); return h; } QVariantMap action(const QString &id, const QVariantMap &champs) { QVariantMap a{{"loadId", id}, {"kind", "setpoint"}, {"reason", kTexteLibre}}; for (auto it = champs.constBegin(); it != champs.constEnd(); ++it) a.insert(it.key(), it.value()); return a; } } // namespace class TestSocketScheduler : public SimulationBase { Q_OBJECT private slots: void testNoEndpointNoObject(); void testValidPlanIsAppliedOnTheNextCycle_data(); void testValidPlanIsAppliedOnTheNextCycle(); void testRefusedPlanFallsBackToRules_data(); void testRefusedPlanFallsBackToRules(); void testOutOfBoundsActionIsClamped(); void testUnavailableLoadKeepsTheShadowAction(); void testReasonIsFilteredOnTheWayToTheApp(); void testL4ShedsALoadTheOptimizerCommands(); void testDegradedModeSuspendsPlanning(); void testBudgetAndDrawAreRecomputedFromTheApplied(); void testTheTriggerNamesTheCause(); void testANewInvalidPlanReplacesTheCache(); 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 testAReconnectionAttemptIsNotANewLoss(); void testEndpointIsSetByRpcAndAppliedHot(); void testTokenIsWriteOnlyAndNeverLeaks(); void testGetPlanPublishesThePlanInForce(); void testGetPlanShowsTheAppliedPowerUnderALock(); void testUpdateLearnedIsLoggedOnceAndIgnored(); // Brief 4 T6 — traces de décision (annexe D) void testTraceIsOffByDefault(); void testTraceSettingByRpc(); void testTraceLineUnderRulesAndInL2(); void testTraceLineUnderTheOptimizer(); void testTraceAbstentionFailureAndNoResponse(); void testTraceRotatesAtLocalMidnightAndPurges(); void testTraceCapStopsUntilTheNextDay(); void testTraceWriteFailureChangesNoDecision(); void testTokenIsInNoTrace(); void testTraceLineSize(); void testTracerPairsTheResponseByRequestId(); // Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part void testAnEchoedTokenLeaksNowhere(); // Lot contrat OpenRPC — le moteur parle le contrat qu'il sert void testTheEngineSpeaksTheContractItServes(); void cleanupTestCase(); private: //! Juge contre le contrat EMBARQUÉ tout ce que le moteur a envoyé au faux optimiseur, puis vide. void jugerLesRequetesDuMoteur(); //! Banc commun ; \p avecOptimiseur faux = rien n'est configuré. void monter(bool avecOptimiseur = true, FakeOptimizer::Transport transport = FakeOptimizer::Unix); //! Un cycle à \p t, compteur à \p compteurW (négatif = export) ; attend le jugement de la //! réponse si le faux optimiseur doit répondre. void cycle(const QDateTime &t, double compteurW); //! Plan d'un créneau couvrant [ts − 60 s, ts + 15 min[, valable 30 min, depuis la requête. static QVariantMap plan(const QVariantMap &requete, const QVariantList &actions, int validiteS = 1800); QHash telemetrie() const { return telemetrieParId(m_arb->loadTelemetry()); } EnergyArbitrator *m_arb = nullptr; Thing *m_compteur = nullptr; QUuid m_relais; QString m_var; QTemporaryDir m_dir; FakeOptimizer *m_serveur = nullptr; QJsonObject m_handshake; QList m_requetes; //! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas. 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); //! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin. QString activerTraces(); //! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir //! au moins \p n (l'écrivain est asynchrone). static QList traces(const QString &dossier, int n = 0); //! T6 : les fichiers de trace de \p dossier, triés. static QStringList fichiersTrace(const QString &dossier); //! T6 : copie les traces de \p dossier sous \c $ETM_POWERSYNC_TRACE_EXPORT (s'il est posé), //! préfixées de \p nom — la matière du test de \c tools/etm-powersync-trace-replay. static void exporterTraces(const QString &dossier, const QString &nom); QTemporaryDir m_dossierTraces; }; 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()); // T6 : ni traces ni réglage de traces hérités d'une fonction précédente. for (const char *cle : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"}) EnergySettings().remove(cle); m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; initTestCase(); m_arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(m_arb); ThingManager *tm = NymeaCore::instance()->thingManager(); const QUuid meterId = addMeter(); m_experiencePlugin->energyManager()->setRootMeter(meterId); m_compteur = tm->findConfiguredThing(meterId); QVERIFY(m_compteur); m_compteur->setStateValue("connected", true); m_relais = addPowerSwitch(1000, 26661); const QUuid varId = addEtmVariableLoad(27043); m_var = varId.toString(); Thing *routeur = tm->findConfiguredThing(varId); QVERIFY(tm->findConfiguredThing(m_relais) && routeur); routeur->setStateValue("currentPowerW", 0); QVariantList l; l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}, {"minOnS", 0}, {"minOffS", 0}}); l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); m_requetes.clear(); m_repondre = nullptr; m_jugees = 0; m_codesJuges.clear(); m_codesEchec.clear(); m_handshakes.clear(); if (!avecOptimiseur) return; QVERIFY(m_dir.isValid()); delete m_serveur; m_serveur = new FakeOptimizer(transport, m_dir.path()); QVERIFY(m_serveur->listen()); if (m_handshake.isEmpty()) m_handshake = handshakeResult(); 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; } if (m.value("method").toString() == QLatin1String("GetPlan")) { const QVariantMap params = m.value("params").toObject().toVariantMap(); m_requetes.append(params); if (m_repondre) { const QVariantMap r = m_repondre(params); 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; } return false; }; EnergySettings().setValue("Optimizer/endpoint", m_serveur->endpoint()); m_arb->relireReglageOptimiseur(); SocketScheduler *s = m_arb->socketScheduler(); QVERIFY(s); connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &code) { ++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; })); } void TestSocketScheduler::cycle(const QDateTime &t, double compteurW) { m_compteur->setStateValue("currentPower", compteurW); const int avant = m_jugees; const int requetes = m_requetes.count(); SocketScheduler *s = m_arb->socketScheduler(); const bool envoi = s && s->client()->state() == OptimizerClient::Ready; m_arb->recordMeterUpdate(t); m_arb->simulationCallUpdate(t); if (envoi) QVERIFY2(waitFor([&] { return m_requetes.count() > requetes; }), "aucune requête GetPlan reçue"); if (envoi && m_repondre) QVERIFY2(waitFor([&] { return m_jugees > avant; }), "réponse du faux optimiseur jamais jugée"); settle(); } QVariantMap TestSocketScheduler::plan(const QVariantMap &requete, const QVariantList &actions, int validiteS) { const QDateTime ts = QDateTime::fromString(requete.value("timestamp").toString(), Qt::ISODate); const QVariantMap creneau{{"from", ts.addSecs(-60).toString(Qt::ISODate)}, {"to", ts.addSecs(900).toString(Qt::ISODate)}, {"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}}; return {{"planId", "plan-test"}, {"strategy", "faux"}, {"validUntil", ts.addSecs(validiteS).toString(Qt::ISODate)}, {"slots", QVariantList{creneau}}, {"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 3}}}}; } void TestSocketScheduler::testNoEndpointNoObject() { // Sans endpoint : AUCUN objet, aucune ligne, aucune connexion — la preuve structurelle de // « identique au bit près ». La preuve comportementale est la référence `decisions`. monter(false); { Journal j; QVERIFY(!m_arb->socketScheduler()); QVERIFY(m_arb->findChildren().isEmpty()); QVERIFY(m_arb->findChildren().isEmpty()); m_arb->relireReglageOptimiseur(); QVERIFY(!m_arb->socketScheduler()); cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000); QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte())); QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien } // Endpoint illisible : dit UNE fois, rien n'est construit. { Journal j; EnergySettings().setValue("Optimizer/endpoint", "http://optimiseur:80"); m_arb->relireReglageOptimiseur(); m_arb->relireReglageOptimiseur(); QVERIFY(!m_arb->socketScheduler()); QVERIFY(m_arb->findChildren().isEmpty()); QCOMPARE(j.compte("Optimizer/endpoint illisible"), 1); } // Revenu à « aucun endpoint » : de nouveau rien, et pas une ligne. { Journal j; EnergySettings().remove("Optimizer/endpoint"); m_arb->relireReglageOptimiseur(); QVERIFY(!m_arb->socketScheduler()); QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte())); } // Endpoint posé puis RETIRÉ : la stratégie et son client disparaissent avec lui. QVERIFY(m_dir.isValid()); FakeOptimizer serveur(FakeOptimizer::Unix, m_dir.path()); QVERIFY(serveur.listen()); EnergySettings().setValue("Optimizer/endpoint", serveur.endpoint()); m_arb->relireReglageOptimiseur(); QVERIFY(m_arb->socketScheduler()); QCOMPARE(m_arb->findChildren().count(), 1); EnergySettings().remove("Optimizer/endpoint"); m_arb->relireReglageOptimiseur(); QVERIFY(!m_arb->socketScheduler()); QVERIFY(m_arb->findChildren().isEmpty()); } void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data() { QTest::addColumn("transport"); QTest::newRow("unix") << int(FakeOptimizer::Unix); QTest::newRow("tcp") << int(FakeOptimizer::Tcp); } void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle() { QFETCH(int, transport); monter(true, FakeOptimizer::Transport(transport)); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "SURPLUS_SETPOINT"}, {"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1000}}}}), action(m_var, {{"powerW", 1800}, {"reasonCode", "PRICE_SLOT"}, {"reasonParams", QVariantMap{{"priceCtkWh", 16.96}}}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); // Cycle 1 : la requête part, rien n'est en cache — le rule-based décide (modèle cache). Journal j; cycle(t0, -3000); QCOMPARE(m_requetes.count(), 1); const QVariantMap req = m_requetes.first(); QCOMPARE(req.value("trigger").toString(), QString("cycle")); QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0); // brut, règle 8 QStringList ids; for (const QVariant &l : req.value("loads").toList()) ids << l.toMap().value("id").toString(); QVERIFY(ids.contains("ecs") && ids.contains(m_var)); // Le fil assemblé par l'arbitre, bout en bout (T1 l'avait laissé au premier appelant) : la // prévision vient de l'instantané RÉEL (sans site désigné : grille, base et prix, `pv: null`), // la mesure de chaque charge de mesurerCharge(), le compteur racine de l'EnergyManager. const QVariantMap prevision = req.value("forecast").toMap(); QVERIFY2(prevision.contains("resolutionS") && prevision.contains("pv"), QJsonDocument::fromVariant(req.value("forecast")).toJson().constData()); QVERIFY(prevision.value("pv").isNull()); QVERIFY(!req.value("meter").toMap().value("source").toString().isEmpty()); for (const QVariant &l : req.value("loads").toList()) QVERIFY2(l.toMap().value("telemetry").toMap().value("measurement").toMap().contains("source"), qUtf8Printable(l.toMap().value("id").toString())); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan")); const int routeurOmbre = qRound(telemetrie().value(m_var).value("allocatedW").toDouble()); QVERIFY2(routeurOmbre != 1800, "le scénario doit distinguer l'optimiseur de l'ombre"); QVERIFY(m_arb->socketScheduler()->aUnPlanEnCache()); // Cycle 2 : le plan reçu s'applique, par le dispatch commun. cycle(t0.addSecs(60), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); const QHash tele = telemetrie(); const QVariantMap ecs = tele.value("ecs"), var = tele.value(m_var); QCOMPARE(ecs.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer")); QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000); QCOMPARE(var.value("decision").toMap().value("code").toString(), QString(DecisionCode::PriceSlot)); QCOMPARE(var.value("decision").toMap().value("params").toMap().value("priceCtkWh").toDouble(), 16.96); QCOMPARE(qRound(var.value("allocatedW").toDouble()), 1800); QCOMPARE(var.value("levels").toList().count(), 1); QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), true); // `expected` et `diagnostics` conservés — jamais pour commander. QCOMPARE(m_arb->socketScheduler()->planApplique().diagnostics.value("status").toString(), QString("optimal")); // Invariant 12 : le texte libre va au journal, JAMAIS dans la charge utile publiée. const QByteArray publie = QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson(); QVERIFY(!publie.contains("LIBRE")); QVERIFY2(j.texte().contains(kTexteLibre), "le texte de l'optimiseur doit être au journal"); QVERIFY(j.texte().contains("stratégie du cycle : optimiseur")); } void TestSocketScheduler::testRefusedPlanFallsBackToRules_data() { QTest::addColumn("cas"); QTest::addColumn("codeJuge"); QTest::addColumn("codeCycle"); // codeCycle = GetPlan.fallbackCode : le verdict de la réponse non appliquée, gardé jusqu'à // l'issue de la requête suivante (correctifs de fin de brief 4, point 3). QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "incomplete-slot"; QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "kind-not-announced"; QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "unknown-load"; QTest::newRow("reason vide") << "reason" << "missing-reason" << "missing-reason"; QTest::newRow("expiré à la réception") << "expire" << "expired" << "expired"; QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired"; QTest::newRow("abstention") << "abstention" << "abstain" << "abstain"; } void TestSocketScheduler::testRefusedPlanFallsBackToRules() { QFETCH(QString, cas); QFETCH(QString, codeJuge); QFETCH(QString, codeCycle); m_handshake = handshakeResult(); if (cas == "kind") // l'optimiseur n'annonce que `state` : ses consignes en watts sont interdites m_handshake.insert("capabilities", QJsonObject{{"kinds", QJsonArray{"state"}}}); monter(); m_repondre = [this, cas](const QVariantMap &req) -> QVariantMap { QVariantMap ecs = action("ecs", {{"powerW", 1000}}); const QVariantMap var = action(m_var, {{"powerW", 1800}}); if (cas == "abstention") return {{"abstain", true}, {"reason", "recalcul"}}; if (cas == "incomplet") return plan(req, {ecs}); if (cas == "inconnu") ecs["loadId"] = "fantome"; if (cas == "reason") ecs["reason"] = ""; if (cas == "expire") return plan(req, {ecs, var}, -1); if (cas == "expire-tard") return plan(req, {ecs, var}, 30); // valable à la réception, plus au cycle suivant return plan(req, {ecs, var}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); Journal j; cycle(t0, -3000); QCOMPARE(m_codesJuges, QStringList{codeJuge}); if (!codeJuge.isEmpty() && codeJuge != "abstain" && codeJuge != "expired") QVERIFY(j.texte().contains("plan REFUSÉ")); // 7-d : le refus se voit cycle(t0.addSecs(60), -3000); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle); const QVariantMap publie = rpc("GetPlan"); QCOMPARE(publie.value("strategy").toString(), QString("rules")); QCOMPARE(publie.value("fallbackCode").toString(), codeCycle); // Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur. for (const QVariantMap &t : telemetrie()) QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer", qUtf8Printable(t.value("loadId").toString())); } void TestSocketScheduler::testOutOfBoundsActionIsClamped() { monter(); const QString d = activerTraces(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); Journal j; cycle(t0, -3000); cycle(t0.addSecs(60), -3000); // L'adaptateur écrête (un seul relais de 1000 W) : c'est l'APPLIQUÉ qui est publié. const QVariantMap ecs = telemetrie().value("ecs"); QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer")); QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000); QCOMPARE(qRound(ecs.value("levels").toList().first().toMap().value("targetW").toDouble()), 1000); QCOMPARE(j.compte("ÉCRÊTÉE par l'adaptateur — demandé 2500 W , appliqué 1000 W"), 1); // Le même écrêtage au cycle suivant ne se répète pas en clair. cycle(t0.addSecs(120), -3000); QCOMPARE(j.compte("ÉCRÊTÉE"), 1); // T6 : la trace dit l'APPLIQUÉ et le marque `clamped` ; la réponse brute garde la demande. const QVariantMap ligne = traces(d, 2).at(1); QCOMPARE(ligne.value("strategy").toString(), QString("optimizer")); bool vu = false; for (const QVariant &v : ligne.value("executed").toList()) { if (v.toMap().value("loadId").toString() != "ecs") continue; vu = true; QCOMPARE(qRound(v.toMap().value("powerW").toDouble()), 1000); QCOMPARE(v.toMap().value("clamped").toBool(), true); } QVERIFY(vu); const QVariantList demandees = ligne.value("response").toMap().value("result").toMap().value("slots") .toList().first().toMap().value("actions").toList(); QCOMPARE(qRound(demandees.first().toMap().value("powerW").toDouble()), 2500); } void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction() { monter(); // Le relais disparaît : l'ECS sort de l'arbitrage (LOAD_UNAVAILABLE, ECS-111). QVERIFY(removeDevice(m_relais).toMap().value("params").toMap().value("thingError").toString() == "ThingErrorNoError"); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1200}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); Journal j; cycle(t0, -3000); QCOMPARE(m_codesJuges, QStringList{QString()}); // valide : Q1 n'exige que les charges disponibles cycle(t0.addSecs(60), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); const QHash tele = telemetrie(); QCOMPARE(tele.value("ecs").value("decision").toMap().value("code").toString(), QString(DecisionCode::LoadUnavailable)); QCOMPARE(tele.value(m_var).value("decision").toMap().value("level").toString(), QString("optimizer")); QCOMPARE(qRound(tele.value(m_var).value("allocatedW").toDouble()), 1200); QVERIFY(j.texte().contains("ecs : charge INDISPONIBLE — action de l'optimiseur rejetée")); // Q1 n'exige QUE les charges disponibles : un plan qui ne nomme pas l'ECS en panne est valide. m_repondre = [this](const QVariantMap &req) { return plan(req, {action(m_var, {{"powerW", 1200}})}); }; cycle(t0.addSecs(120), -3000); QCOMPARE(m_codesJuges.last(), QString()); cycle(t0.addSecs(180), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); } void TestSocketScheduler::testReasonIsFilteredOnTheWayToTheApp() { monter(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "PRICE_SLOT"}, {"reasonParams", QVariantMap{{"priceCtkWh", 12.5}, {"rang", 3}, // non documentée {"setpointW", "mille watts"}}}}), action(m_var, {{"powerW", 500}, {"reasonCode", "DEGRADED_L2"}, {"reasonParams", QVariantMap{{"x", 1}}}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); Journal j; cycle(t0, -3000); cycle(t0.addSecs(60), -3000); const QHash tele = telemetrie(); const QVariantMap ecs = tele.value("ecs").value("decision").toMap(); QCOMPARE(ecs.value("code").toString(), QString(DecisionCode::PriceSlot)); QCOMPARE(ecs.value("params").toMap(), (QVariantMap{{"priceCtkWh", 12.5}})); // Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité. const QVariantMap var = tele.value(m_var).value("decision").toMap(); QCOMPARE(var.value("code").toString(), QString(DecisionCode::OptimizerDecision)); QVERIFY(var.value("params").toMap().isEmpty()); // Une clé non documentée (même numérique) et une valeur texte : supprimées, et dites. QVERIFY(j.texte().contains("paramètres supprimés : rang,setpointW")); QVERIFY(j.texte().contains("reçu : DEGRADED_L2 → OPTIMIZER_DECISION")); QVERIFY(!QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson().contains("mille watts")); } void TestSocketScheduler::testL4ShedsALoadTheOptimizerCommands() { monter(); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})}); }; for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"}) m_compteur->setStateValue(p, 0); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais); QCOMPARE(relais->stateValue("power").toBool(), true); // l'optimiseur l'a enclenché // Surcharge : L4 déleste, et il a le dernier mot sur le plan de l'optimiseur, toujours en cache. m_compteur->setStateValue("currentPhaseA", 30); cycle(t0.addSecs(70), 6900); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(relais->stateValue("power").toBool(), false); QCOMPARE(telemetrie().value("ecs").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap)); } void TestSocketScheduler::testDegradedModeSuspendsPlanning() { monter(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); const int requetes = m_requetes.count(); // Compteur muet > 90 s : mode dégradé L2, la planification est suspendue — AUCUNE stratégie, // ni requête ni application, quel que soit le plan en cache. m_arb->evaluateMeterFreshness(t0.addSecs(200)); QVERIFY(m_arb->degradedMode()); m_arb->simulationCallUpdate(t0.addSecs(240)); settle(200); QCOMPARE(m_requetes.count(), requetes); QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), false); // le repli L2 a coupé l'ECS, et personne ne l'a rallumé } void TestSocketScheduler::testBudgetAndDrawAreRecomputedFromTheApplied() { monter(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1500}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -500); const QVariantMap drawOmbre = m_arb->loadTelemetry().value("draw").toMap(); cycle(t0.addSecs(60), -500); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); const QVariantMap snap = m_arb->loadTelemetry(); const QVariantMap budget = snap.value("budget").toMap(), draw = snap.value("draw").toMap(); // Q4 — la mesure, puis ce qui a été APPLIQUÉ : 2500 W appliqués sur 500 W d'export. QCOMPARE(budget.value("surplusW").toDouble(), 500.0); QCOMPARE(budget.value("evReservedW").toDouble(), 0.0); const double surplus = budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble(); QCOMPARE(qRound(surplus - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble())); QCOMPARE(qRound(budget.value("allocatedW").toDouble() + draw.value("committedW").toDouble()), 2500); QCOMPARE(qRound(budget.value("allocatedW").toDouble()), qRound(qMin(surplus, 2500.0))); QVERIFY(draw.value("committedW").toDouble() > 0); QVERIFY2(qRound(drawOmbre.value("committedW").toDouble()) == 0, "l'ombre n'achetait rien"); // Les registres se réconcilient par charge : Σ counts == budget.allocatedW et draw.committedW. double alloue = 0, achete = 0; for (const QVariantMap &t : telemetrie()) { for (const QVariant &n : t.value("levels").toList()) { const QVariantMap c = n.toMap().value("counts").toMap(); alloue += c.value(CountKey::BudgetAllocated).toDouble(); achete += c.value(CountKey::DrawCommitted).toDouble(); } // Q3 — financement dérivé de la mesure : ici l'export ne couvre pas, le reliquat est acheté. QCOMPARE(t.value("levels").toList().count(), 1); } QCOMPARE(qRound(alloue), qRound(budget.value("allocatedW").toDouble())); QCOMPARE(qRound(achete), qRound(draw.value("committedW").toDouble())); QCOMPARE(telemetrie().value(m_var).value("levels").toList().first().toMap().value("funding").toString(), QString("grid")); } void TestSocketScheduler::testTheTriggerNamesTheCause() { monter(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle")); // Une configuration réécrite (SetLoadConfig → reconstruction) : `config`. const QVariantList cfg = injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap() .value("loadConfigs").toList(); QVERIFY(!cfg.isEmpty()); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfg}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); cycle(t0.addSecs(60), -3000); QCOMPARE(m_requetes.last().value("trigger").toString(), QString("config")); // Consommé : le cycle suivant redevient `cycle`. cycle(t0.addSecs(120), -3000); QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle")); // Un Thing ajouté : `load-change`. addPowerSwitch(500, 26662); cycle(t0.addSecs(180), -3000); QCOMPARE(m_requetes.last().value("trigger").toString(), QString("load-change")); // Plusieurs causes : la plus significative l'emporte. m_arb->socketScheduler()->noterDeclencheur("forecast"); m_arb->socketScheduler()->noterDeclencheur("intent"); m_arb->socketScheduler()->noterDeclencheur("config"); cycle(t0.addSecs(240), -3000); QCOMPARE(m_requetes.last().value("trigger").toString(), QString("intent")); } void TestSocketScheduler::testANewInvalidPlanReplacesTheCache() { // Un plan valide est en cache ; la réponse suivante est invalide. Elle ne s'applique pas, et // l'ancien non plus : un optimiseur qui vient de répondre faux n'est pas cru sur sa réponse // d'avant (§7 — le compteur d'échecs viendra en T4, le repli est immédiat). monter(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); }; cycle(t0.addSecs(60), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le premier plan, appliqué QCOMPARE(m_codesJuges.last(), QString("incomplete-slot")); cycle(t0.addSecs(120), -3000); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("incomplete-slot")); } void TestSocketScheduler::testConnectionLossDropsThePlan() { // §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté. monter(); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); m_serveur->responder = nullptr; // plus de handshake : la connexion ne revient pas m_serveur->abortClient(); SocketScheduler *s = m_arb->socketScheduler(); QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; })); QVERIFY(!s->aUnPlanEnCache()); cycle(t0.addSecs(120), -3000); QVERIFY(!s->optimiseurAuDernierCycle()); QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan")); } void TestSocketScheduler::testThePlanOfAnOptimizerBuysNothingEco() { // Correctif c, cas (vii) — sous un plan d'optimiseur, l'ombre rule-based tourne encore et // calcule sa cible éco ; elle n'est pas exécutée. L'acheté se compte sur ce qui a été // APPLIQUÉ, et une action d'optimiseur (niveau unique `optimizer`) n'a pas de part éco : rien // n'est compté, même quand le compteur de la charge avance. monter(); ThingManager *tm = NymeaCore::instance()->thingManager(); const QUuid compteurId = addMeter(26664); Thing *compteur = tm->findConfiguredThing(compteurId); QVERIFY(compteur); compteur->setStateValue("connected", true); compteur->setStateValue("currentPower", 0); // le test pose lui-même l'énergie compteur->setStateValue("totalEnergyConsumed", 10.0); auto ajouterWh = [&](double wh) { compteur->setStateValue("totalEnergyConsumed", compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0); }; // L'ECS reçoit une obligation (2 000 Wh d'ici 07:00, soit 1 000 W à 05:00 : son relais // entier) et un compteur d'énergie. Le routeur, sans obligation, ne sert que Q1. QVariantList l; l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, {"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}, {"meterThingId", compteurId.toString()}, {"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}}}}); l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})}); }; auto eco = [this]() { for (const QVariant &n : telemetrie().value("ecs").value("levels").toList()) if (n.toMap().value("level").toString() == QLatin1String("eco")) return n.toMap().value("progress").toMap(); return QVariantMap(); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(5, 0, 0)); cycle(t0, 2000); // rule-based : éco 1 000 W, relais fermé QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(eco().value("purchasedWh").toDouble(), 0.0); cycle(t0.addSecs(60), 2000); // le plan de l'optimiseur s'applique QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); QCOMPARE(tm->findConfiguredThing(m_relais)->stateValue("power").toBool(), true); // 250 Wh passent pendant que l'OPTIMISEUR commande ; puis il répond faux, et l'on revient // aux règles — le seul moment où le niveau éco, donc l'acheté, se publie de nouveau. ajouterWh(250); m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); }; cycle(t0.addSecs(120), 2000); // plan d'avant appliqué ; le nouveau, refusé QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); cycle(t0.addSecs(180), 2000); QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); const QVariantMap p = eco(); QCOMPARE(p.value("regime").toString(), QString("measured")); QCOMPARE(qRound(p.value("deliveredWh").toDouble()), 250); // l'énergie est bien arrivée… QVERIFY2(p.contains("purchasedWh"), "sous measured, l'acheté est publié"); QCOMPARE(p.value("purchasedWh").toDouble(), 0.0); // …et l'éco n'en a rien acheté } void TestSocketScheduler::testPvIsReadFromAnyProducer() { // `pv.currentW` se lit dès qu'un `smartmeterproducer` existe — pas seulement un // `solarinverter`, qui l'ÉTEND dans libnymea. Le producteur du mock est exactement le cas // du banc : un compteur de production (sdm120Producer) posé sur un onduleur non // communicant, `smartmeterproducer` et rien d'autre. monter(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); EnergyManagerMock *em = dynamic_cast(m_experiencePlugin->energyManager()); QVERIFY(em); em->setCurrentPowerProduction(-2400); // convention nymea : produite = négative // Sans producteur : inconnu (null), même si l'EnergyManager rend un chiffre — 0 W ne // distinguerait pas la nuit de l'absence de PV. cycle(t0, -3000); QVariantMap pv = m_requetes.last().value("pv").toMap(); QVERIFY(pv.contains("currentW")); QVERIFY(pv.value("currentW").isNull()); const QUuid producteur = addSolarInverter(); Thing *t = NymeaCore::instance()->thingManager()->findConfiguredThing(producteur); QVERIFY(t); QVERIFY2(t->thingClass().interfaces().contains("smartmeterproducer") && !t->thingClass().interfaces().contains("solarinverter"), "le cas éprouvé est un producteur qui N'EST PAS un onduleur"); cycle(t0.addSecs(60), -3000); pv = m_requetes.last().value("pv").toMap(); QVERIFY2(!pv.value("currentW").isNull(), "un smartmeterproducer seul doit suffire"); 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("rules")); 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("rules")); // 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("abstain")); } 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::testAReconnectionAttemptIsNotANewLoss() { // Constat `drop` (brief 4 T7) : pendant une panne, chaque tentative de reconnexion au backoff // n'est PAS une nouvelle perte. Une seule transition à la perte, une seule au retour ; entre // les deux, ni OptimizerStatusChanged, ni etatChange(), ni dernier code réécrit. monter(); enableNotifications({"NymeaEnergy"}); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); amenerAOptimiseur(t0); SocketScheduler *s = m_arb->socketScheduler(); QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData); QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged); QSignalSpy etats(s, &SocketScheduler::etatChange); QSignalSpy client(s->client(), &OptimizerClient::stateChanged); auto tentatives = [&client] { int n = 0; for (const QList &a : client) n += a.first().value() == OptimizerClient::Connecting; return n; }; // Perte : l'optimiseur disparaît, puis N = 3 tentatives échouent au backoff (1, 2, 4 s). m_serveur->fermer(); QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Waiting; })); QCOMPARE(etats.count(), 1); QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"}); QVERIFY2(waitFor([&] { return tentatives() >= 3 && s->client()->state() == OptimizerClient::Waiting; }, 15000), qPrintable(QString::number(tentatives()))); QCOMPARE(tentatives(), 3); // Les tentatives ont bien échoué, avec un autre code que la perte : le test serait aveugle sinon. QCOMPARE(s->client()->lastErrorCode(), QString("connect-failed")); QCOMPARE(etats.count(), 1); QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"}); QVariantMap st = rpc("GetOptimizerStatus"); QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost")); QCOMPARE(st.value("consecutiveFailures").toInt(), 0); QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 1); // Retour : l'optimiseur réécoute au même endpoint ; la tentative suivante aboutit. QVERIFY(m_serveur->listen()); QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 20000)); QCOMPARE(etats.count(), 2); // le handshake accepté (identité), et rien d'autre cycle(t0.addSecs(120), -3000); cycle(t0.addSecs(180), -3000); QVERIFY(s->optimiseurAuDernierCycle()); QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"})); QCOMPARE(checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged").count(), 3); st = rpc("GetOptimizerStatus"); QCOMPARE(st.value("lastErrorCode").toString(), QString("connection-lost")); // jamais effacé (T4 Q5) QCOMPARE(st.value("consecutiveFailures").toInt(), 0); } 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 un blanc dedans"}}); QCOMPARE(r.value("fault").toString(), QString("token-refused")); // Trop court (15 caractères) : refusé de même, par code, sans rien changer. r = rpc("SetOptimizerToken", {{"token", "quinze-car-1234"}}); QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter")); QCOMPARE(r.value("fault").toString(), QString("token-refused")); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret); f.close(); QVERIFY(j.texte().contains("moins de 16 caractères")); // 16 caractères : accepté. QCOMPARE(rpc("SetOptimizerToken", {{"token", "seize-car-123456"}}).value("energyError").toString(), QString("EnergyErrorNoError")); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), QString("seize-car-123456")); 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'))); for (const QString &x : {QStringLiteral("quinze-car-1234"), QStringLiteral("seize-car-123456")}) QVERIFY2(horsTrafic(x).isEmpty(), qUtf8Printable(horsTrafic(x).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("rules")); 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::testGetPlanShowsTheAppliedPowerUnderALock() { // Le créneau courant publie ce que l'adaptateur a APPLIQUÉ, pas ce que la stratégie a // demandé. Sous l'optimiseur les deux coïncident déjà dans le créneau (finaliser(), T3) ; // le cas qui les sépare est le rule-based face à un verrou interne à l'adaptateur. monter(false); QVariantList l; l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"minOnS", 600}, {"minOffS", 0}, {"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}}); QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); auto ecs = [this]() { for (const QVariant &a : rpc("GetPlan").value("slots").toList().first().toMap().value("actions").toList()) if (a.toMap().value("loadId").toString() == QLatin1String("ecs")) return a.toMap(); return QVariantMap(); }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000); Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais); QCOMPARE(relais->stateValue("power").toBool(), true); // Import : la stratégie veut couper, le verrou minOn (600 s) tient le relais fermé. cycle(t0.addSecs(60), 2500); QCOMPARE(relais->stateValue("power").toBool(), true); QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000); } 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")); } // --- Brief 4 T6 : traces de décision (OPTIMIZER_PROTOCOL annexe D) ---------------------------- QString TestSocketScheduler::activerTraces() { static int n = 0; const QString d = m_dossierTraces.path() + QStringLiteral("/traces-%1").arg(++n); QDir().mkpath(d); m_arb->setOptimizerTraceDirectory(d); const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}}); [&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }(); [&] { QVERIFY(m_arb->optimizerTracer()); }(); return d; } QStringList TestSocketScheduler::fichiersTrace(const QString &dossier) { return QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name); } void TestSocketScheduler::exporterTraces(const QString &dossier, const QString &nom) { const QString vers = qEnvironmentVariable("ETM_POWERSYNC_TRACE_EXPORT"); if (vers.isEmpty()) return; QDir().mkpath(vers); for (const QString &f : fichiersTrace(dossier)) { QFile::remove(QDir(vers).filePath(nom + "-" + f)); QFile::copy(QDir(dossier).filePath(f), QDir(vers).filePath(nom + "-" + f)); } } QList TestSocketScheduler::traces(const QString &dossier, int n) { QList lignes; auto lire = [&] { lignes.clear(); for (const QString &nom : fichiersTrace(dossier)) { QFile f(QDir(dossier).filePath(nom)); if (!f.open(QIODevice::ReadOnly)) continue; for (const QByteArray &l : f.readAll().split('\n')) if (!l.isEmpty()) lignes.append(QJsonDocument::fromJson(l).object().toVariantMap()); } return lignes.count() >= n; }; waitFor(lire, 3000); return lignes; } void TestSocketScheduler::testTraceIsOffByDefault() { // Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal. monter(false); QVERIFY(!m_arb->optimizerTracer()); QVERIFY(m_arb->findChildren().isEmpty()); const QVariantMap g = rpc("GetOptimizerTrace"); QCOMPARE(g.value("enabled").toBool(), false); QCOMPARE(g.value("retentionDays").toInt(), 14); QCOMPARE(g.value("maxBytesPerDay").toLongLong(), OptimizerTrace::PlafondDefautOctets); QCOMPARE(g.value("directory").toString(), NymeaSettings::storagePath()); Journal j; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); settle(200); QVERIFY(fichiersTrace(NymeaSettings::storagePath()).isEmpty()); QVERIFY(!j.texte().contains("[Trace]")); QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien } void TestSocketScheduler::testTraceSettingByRpc() { monter(false); // Refus : une valeur hors bornes ou du mauvais type ne change RIEN, pas même les autres clés // de l'appel (le type est contrôlé par le moteur : le validateur Qt 6 ne le fait pas). const QList refus{ {{"retentionDays", 0}}, {{"retentionDays", 366}}, {{"retentionDays", 1.5}}, {{"retentionDays", "14"}}, {{"maxBytesPerDay", 1000}}, {{"maxBytesPerDay", 2147483647}}, {{"enabled", "yes"}}, {{"enabled", true}, {"retentionDays", 0}}}; for (const QVariantMap &p : refus) { const QVariantMap r = rpc("SetOptimizerTrace", p); QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter")); QCOMPARE(r.value("fault").toString(), QString("invalid-trace-setting")); QVERIFY(!m_arb->optimizerTracer()); QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 14); QVERIFY(!EnergySettings().contains("Optimizer/trace")); } // Pose : persistée dans energy.conf, appliquée à chaud. const QString d = m_dossierTraces.path() + "/reglage"; QDir().mkpath(d); m_arb->setOptimizerTraceDirectory(d); QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}, {"retentionDays", 3}, {"maxBytesPerDay", 2097152}}); QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); OptimizerTracer *tr = m_arb->optimizerTracer(); QVERIFY(tr); QCOMPARE(tr->reglage().retentionJours, 3); QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152)); QCOMPARE(EnergySettings().value("Optimizer/trace").toBool(), true); QCOMPARE(EnergySettings().value("Optimizer/traceRetentionDays").toInt(), 3); QCOMPARE(EnergySettings().value("Optimizer/traceMaxBytesPerDay").toLongLong(), 2097152ll); QCOMPARE(QJsonObject::fromVariantMap(rpc("GetOptimizerTrace")), QJsonObject::fromVariantMap({{"enabled", true}, {"retentionDays", 3}, {"maxBytesPerDay", 2097152}, {"directory", d}})); // Une clé seule : les autres restent, le traceur n'est pas reconstruit. rpc("SetOptimizerTrace", {{"retentionDays", 7}}); QCOMPARE(m_arb->optimizerTracer(), tr); QCOMPARE(tr->reglage().retentionJours, 7); QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152)); // Désactivation : le traceur disparaît, la ligne de réglage reste lisible. rpc("SetOptimizerTrace", {{"enabled", false}}); QVERIFY(!m_arb->optimizerTracer()); QCOMPARE(rpc("GetOptimizerTrace").value("enabled").toBool(), false); QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 7); } void TestSocketScheduler::testTraceLineUnderRulesAndInL2() { // Sans endpoint : la requête qui SERAIT partie, et le plan du rule-based comme réponse. monter(false); const QString d = activerTraces(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); QList l = traces(d, 2); QCOMPARE(l.count(), 2); const QVariantMap a = l.last(); QStringList cles = a.keys(); cles.sort(); QCOMPARE(cles, (QStringList{"executed", "request", "response", "strategy", "t"})); QCOMPARE(a.value("t").toString(), OptimizerTrace::horodatage(t0.addSecs(60))); QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate), t0.addSecs(60)); // L'heure LOCALE, décalage explicite (annexe D) — pas l'UTC. QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate).offsetFromUtc(), t0.toLocalTime().offsetFromUtc()); QVERIFY(!a.value("t").toString().endsWith('Z')); QCOMPARE(a.value("strategy").toString(), QString("rules")); const QVariantMap req = a.value("request").toMap(); QCOMPARE(req.value("trigger").toString(), QString("cycle")); QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0); QVERIFY(req.contains("forecast") && req.contains("timestamp")); QStringList ids; for (const QVariant &c : req.value("loads").toList()) ids << c.toMap().value("id").toString(); QVERIFY(ids.contains("ecs") && ids.contains(m_var)); const QVariantMap rep = a.value("response").toMap().value("result").toMap(); QCOMPARE(rep.value("strategy").toString(), QString("rules")); QCOMPARE(rep.value("slots").toList().count(), 1); const QHash tele = telemetrie(); for (const QVariant &v : rep.value("slots").toList().first().toMap().value("actions").toList()) { const QVariantMap act = v.toMap(); QVERIFY(!act.value("reasonCode").toString().isEmpty()); QVERIFY(!act.contains("reason")); // des codes, jamais de texte } QCOMPARE(a.value("executed").toList().count(), tele.count()); for (const QVariant &v : a.value("executed").toList()) { const QVariantMap e = v.toMap(); QVERIFY2(tele.contains(e.value("loadId").toString()), qUtf8Printable(e.value("loadId").toString())); QCOMPARE(e.value("kind").toString(), QString("setpoint")); QCOMPARE(qRound(e.value("powerW").toDouble()), qRound(tele.value(e.value("loadId").toString()).value("allocatedW").toDouble())); QCOMPARE(e.value("clamped").toBool(), false); } QVERIFY(!a.contains("latencyMs")); // L2 : une ligne `none`, SANS requête ni réponse, rien d'exécuté par la planification. m_arb->evaluateMeterFreshness(t0.addSecs(200)); QVERIFY(m_arb->degradedMode()); m_arb->simulationCallUpdate(t0.addSecs(240)); l = traces(d, 3); QCOMPARE(l.count(), 3); QCOMPARE(l.last(), (QVariantMap{{"t", OptimizerTrace::horodatage(t0.addSecs(240))}, {"executed", QVariantList()}, {"strategy", "none"}})); exporterTraces(d, "regles"); } void TestSocketScheduler::testTraceLineUnderTheOptimizer() { monter(); const QString d = activerTraces(); m_repondre = planValide(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); const QList l = traces(d, 2); QCOMPARE(l.count(), 2); QCOMPARE(m_requetes.count(), 2); for (int i = 0; i < 2; ++i) { // La requête RÉELLEMENT envoyée, et la réponse BRUTE à CETTE requête, avec sa latence. QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("request").toMap()), QJsonObject::fromVariantMap(m_requetes.at(i))); QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("response").toMap().value("result").toMap()), QJsonObject::fromVariantMap(planValide()(m_requetes.at(i)))); QVERIFY(l.at(i).value("latencyMs").toLongLong() >= 0); // Le texte libre de l'optimiseur n'est QUE dans la réponse brute. QVariantMap sansReponse = l.at(i); sansReponse.remove("response"); QVERIFY(!QJsonDocument::fromVariant(sansReponse).toJson().contains("LIBRE")); } // Cycle 1 : rien en cache, le rule-based a commandé ; cycle 2 : le plan reçu au cycle 1. QCOMPARE(l.at(0).value("strategy").toString(), QString("rules")); QCOMPARE(l.at(1).value("strategy").toString(), QString("optimizer")); QHash puissances; for (const QVariant &v : l.at(1).value("executed").toList()) { puissances.insert(v.toMap().value("loadId").toString(), v.toMap().value("powerW").toDouble()); QCOMPARE(v.toMap().value("clamped").toBool(), false); } QCOMPARE(puissances.value("ecs"), 1000.0); QCOMPARE(puissances.value(m_var), 500.0); exporterTraces(d, "optimiseur"); } void TestSocketScheduler::testTraceAbstentionFailureAndNoResponse() { monter(); const QString d = activerTraces(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; }; cycle(t0, -3000); m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; }; cycle(t0.addSecs(60), -3000); m_repondre = nullptr; // ne répond plus : la requête sera remplacée sans réponse cycle(t0.addSecs(120), -3000); cycle(t0.addSecs(180), -3000); const QList l = traces(d, 3); QCOMPARE(l.count(), 3); QCOMPARE(l.at(0).value("response").toMap(), (QVariantMap{{"result", QVariantMap{{"abstain", true}, {"reason", "recalcul"}}}})); QCOMPARE(l.at(1).value("response").toMap(), (QVariantMap{{"error", QVariantMap{{"code", "rpc-error"}}}})); QVERIFY(l.at(1).contains("latencyMs")); QCOMPARE(l.at(2).value("response").toMap(), (QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}})); QVERIFY(!l.at(2).contains("latencyMs")); for (const QVariantMap &x : l) { QCOMPARE(x.value("strategy").toString(), QString("rules")); QVERIFY(x.contains("request") && !x.value("executed").toList().isEmpty()); } // La 4e ligne attend encore sa réponse : elle part au cycle suivant, ou quand l'arbitre // abandonne les traces (désactivation) — jamais perdue en silence. rpc("SetOptimizerTrace", {{"enabled", false}}); QCOMPARE(traces(d, 4).last().value("response").toMap(), (QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}})); exporterTraces(d, "repli"); } void TestSocketScheduler::testTraceRotatesAtLocalMidnightAndPurges() { monter(false); const QString d = activerTraces(); const QDate jour(2026, 6, 8); auto poser = [&](const QString &nom) { QFile f(QDir(d).filePath(nom)); QVERIFY(f.open(QIODevice::WriteOnly)); f.write("{}\n"); }; poser(OptimizerTrace::nomFichier(jour.addDays(-14))); // hors rétention : purgé poser(OptimizerTrace::nomFichier(jour.addDays(-13))); // dans la rétention (14 jours, ce jour compris) poser("etm-powersync-optimizer-trace-pas-une-date.ndjson"); poser("autre.ndjson"); Journal j; // 23:59 puis 00:00:30 HEURE LOCALE : la rotation suit le jour local du cycle. cycle(QDateTime(jour, QTime(23, 59, 0)), -3000); traces(d, 3); QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour.addDays(-13)), OptimizerTrace::nomFichier(jour), "etm-powersync-optimizer-trace-pas-une-date.ndjson"})); cycle(QDateTime(jour.addDays(1), QTime(0, 0, 30)), -3000); traces(d, 4); QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour), OptimizerTrace::nomFichier(jour.addDays(1)), "etm-powersync-optimizer-trace-pas-une-date.ndjson"})); QVERIFY(QFile::exists(QDir(d).filePath("autre.ndjson"))); QFile f(QDir(d).filePath(OptimizerTrace::nomFichier(jour.addDays(1)))); QVERIFY(f.open(QIODevice::ReadOnly)); const QVariantMap ligne = QJsonDocument::fromJson(f.readLine()).object().toVariantMap(); QCOMPARE(QDateTime::fromString(ligne.value("t").toString(), Qt::ISODate).toLocalTime().date(), jour.addDays(1)); // Rétention réglée à chaud : 1 jour = le fichier du jour seul. rpc("SetOptimizerTrace", {{"retentionDays", 1}}); QVERIFY(waitFor([&] { return !fichiersTrace(d).contains(OptimizerTrace::nomFichier(jour)); })); QCOMPARE(j.compte("[Trace]"), j.compte("[Trace] 1 fichier(s)") + j.compte("[Trace] réglage appliqué")); } void TestSocketScheduler::testTraceCapStopsUntilTheNextDay() { monter(false); const QString d = activerTraces(); rpc("SetOptimizerTrace", {{"maxBytesPerDay", OptimizerTrace::PlafondMinOctets}}); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); const QString chemin = QDir(d).filePath(OptimizerTrace::nomFichier(t0.toLocalTime().date())); { QFile f(chemin); QVERIFY(f.open(QIODevice::WriteOnly)); f.write(QByteArray(OptimizerTrace::PlafondMinOctets - 100, 'x') + '\n'); } const qint64 avant = QFileInfo(chemin).size(); Journal j; for (int i = 0; i < 4; ++i) cycle(t0.addSecs(60 * i), -3000); settle(300); QCOMPARE(QFileInfo(chemin).size(), avant); // plus rien ce jour-là QCOMPARE(j.compte("plafond journalier atteint"), 1); // Le lendemain : de nouveau écrit. cycle(t0.addDays(1), -3000); const QString demain = QDir(d).filePath(OptimizerTrace::nomFichier(t0.addDays(1).toLocalTime().date())); QVERIFY(waitFor([&] { return QFileInfo(demain).size() > 0; })); QCOMPARE(j.compte("plafond journalier atteint"), 1); } void TestSocketScheduler::testTraceWriteFailureChangesNoDecision() { if (::geteuid() == 0) QSKIP("root écrit dans un répertoire en lecture seule"); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); const QList compteur{-3000, -1500, -500, -2200}; // Ce que décide chaque cycle, par RÔLE de charge (l'identifiant du routeur change d'un banc à // l'autre) : puissance allouée et code du motif. auto releve = [&] { QStringList r; const QHash t = telemetrie(); for (const QString &id : {QStringLiteral("ecs"), m_var}) r << QStringLiteral("%1:%2").arg(qRound(t.value(id).value("allocatedW").toDouble())) .arg(t.value(id).value("decision").toMap().value("code").toString()); return r.join('|'); }; monter(false); QStringList reference; for (int i = 0; i < compteur.count(); ++i) { cycle(t0.addSecs(60 * i), compteur.at(i)); reference << releve(); } monter(false); const QString d = activerTraces(); QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::ExeOwner)); Journal j; QStringList obtenu; for (int i = 0; i < compteur.count(); ++i) { cycle(t0.addSecs(60 * i), compteur.at(i)); obtenu << releve(); } settle(300); QCOMPARE(obtenu, reference); QVERIFY(fichiersTrace(d).isEmpty()); QCOMPARE(j.compte("[Trace] écriture impossible"), 1); // Le répertoire redevient inscriptible : l'écriture reprend, et c'est dit une fois. QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); cycle(t0.addSecs(600), -3000); QCOMPARE(traces(d, 1).count(), 1); QCOMPARE(j.compte("[Trace] écriture rétablie"), 1); QCOMPARE(j.compte("[Trace] écriture impossible"), 1); } void TestSocketScheduler::testTokenIsInNoTrace() { monter(); // Un secret avec les deux caractères que JSON échappe : le masquage doit le reconnaître écrit. const QString secret = QStringLiteral("T6-secret\"qui\\ne-sort-pas-0123456789"); QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(), QString("EnergyErrorNoError")); const QString d = activerTraces(); // Un optimiseur qui RECOPIE le token dans sa réponse (motif, identifiant de plan). m_repondre = [this, secret](const QVariantMap &req) { QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret}}), action(m_var, {{"powerW", 500}})}); p.insert("planId", secret); return p; }; Journal j; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); QCOMPARE(traces(d, 2).count(), 2); QByteArray tout; for (const QString &nom : fichiersTrace(d)) { QFile f(QDir(d).filePath(nom)); QVERIFY(f.open(QIODevice::ReadOnly)); tout += f.readAll(); } const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2); QVERIFY(!tout.contains(secret.toUtf8())); QVERIFY(!tout.contains(echappe)); QVERIFY(!tout.contains("0123456789")); // pas même un morceau QCOMPARE(tout.count("[token masqué]"), 4); // 2 réponses × (motif + planId) QCOMPARE(j.compte("recopie le token"), 1); // La requête ne l'a jamais porté : il ne voyage que dans le handshake. for (const QVariantMap &r : std::as_const(m_requetes)) QVERIFY(!QJsonDocument::fromVariant(r).toJson().contains("0123456789")); QFile::remove(OptimizerClient::defaultTokenPath()); } void TestSocketScheduler::testTraceLineSize() { // MESURE (brief 4 T6, banc Pi Zero 2 W sur carte SD) : la taille d'une ligne au CONTEXTE // COMPLET — la requête réelle d'un cycle, sa prévision portée à 48 créneaux horaires (PV par // plan, base, météo, prix import/export), quatre charges — sous le rule-based et sous un // optimiseur qui rend 48 créneaux. Sérialisée comme l'écrit l'écrivain (JSON compact). monter(); m_repondre = planValide(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); QVariantMap req = m_requetes.first(); auto serie = [](double base, double pas) { QVariantList l; for (int i = 0; i < 48; ++i) l << qRound((base + pas * ((i * 7) % 13)) * 10) / 10.0; return l; }; QVariantList plans; for (int k = 0; k < 2; ++k) plans << QVariantMap{{"id", QUuid::createUuid().toString()}, {"label", k ? "Toit ouest" : "Toit sud"}, {"peakW", 4500}, {"tiltDeg", 35}, {"azimuthDeg", k ? 90 : 0}, {"corrections", QVariantList{"temperature"}}, {"p50W", serie(1200, 310)}}; req.insert("forecast", QVariantMap{ {"start", t0.toString(Qt::ISODate)}, {"resolutionS", 3600}, {"steps", 48}, {"pv", QVariantMap{{"provider", "open-meteo"}, {"weatherModel", "meteofrance_seamless"}, {"conversion", "physics"}, {"corrections", QVariantList{"temperature", "ac-limit"}}, {"issuedAt", t0.toString(Qt::ISODate)}, {"contentChangedAt", t0.toString(Qt::ISODate)}, {"acLimitW", 8000}, {"p50W", serie(2400, 620)}, {"perField", plans}}}, {"baseload", QVariantMap{{"model", "profile"}, {"p50W", serie(380, 45)}, {"quality", QVariantMap{{"windowDays", 28}, {"minutes", 28800}}}}}, {"weather", QVariantMap{{"temperatureC", serie(8, 1.3)}}}, {"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", serie(18.5, 2.1)}, {"exportCtkWh", serie(4.2, 0.6)}, {"includes", QVariantList{"energy", "grid", "taxes"}}, {"source", "tempo"}, {"knownUntil", t0.addDays(1).toString(Qt::ISODate)}}}}); QVariantList charges = req.value("loads").toList(); for (int k = 0; charges.count() < 4; ++k) { QVariantMap c = charges.at(k).toMap(); c.insert("id", QUuid::createUuid().toString()); charges << c; } req.insert("loads", charges); QVariantList executees, actionsRegles; for (const QVariant &c : charges) { const QString id = c.toMap().value("id").toString(); executees << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5}, {"clamped", false}}; actionsRegles << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5}, {"reasonCode", "SURPLUS_SETPOINT"}, {"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1234.5}}}}; } QVariantList creneaux; for (int i = 0; i < 48; ++i) { QVariantList actions; for (const QVariant &c : charges) actions << QVariantMap{{"loadId", c.toMap().value("id")}, {"kind", "setpoint"}, {"powerW", 1500}, {"reasonCode", "SURPLUS_SETPOINT"}, {"reason", "surplus prévu sur ce créneau"}}; creneaux << QVariantMap{{"from", t0.addSecs(3600 * i).toString(Qt::ISODate)}, {"to", t0.addSecs(3600 * (i + 1)).toString(Qt::ISODate)}, {"actions", actions}, {"expected", QVariantMap{{"gridImportW", 120}, {"gridExportW", 0}}}}; } auto taille = [](const QVariantMap &l) { return QJsonDocument(QJsonObject::fromVariantMap(l)).toJson(QJsonDocument::Compact).size() + 1; }; const QVariantMap regles{{"t", OptimizerTrace::horodatage(t0)}, {"request", req}, {"response", QVariantMap{{"result", QVariantMap{ {"strategy", "rules"}, {"computedAt", t0.toString(Qt::ISODate)}, {"validUntil", t0.addSecs(60).toString(Qt::ISODate)}, {"slots", QVariantList{QVariantMap{{"from", t0.toString(Qt::ISODate)}, {"to", t0.addSecs(60).toString(Qt::ISODate)}, {"actions", actionsRegles}}}}}}}}, {"executed", executees}, {"strategy", "rules"}}; QVariantMap optimiseur = regles; optimiseur.insert("response", QVariantMap{{"result", QVariantMap{ {"planId", QUuid::createUuid().toString()}, {"strategy", "etm-optimizer"}, {"validUntil", t0.addSecs(1800).toString(Qt::ISODate)}, {"slots", creneaux}, {"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 412}, {"solver", "highs"}}}}}}); optimiseur.insert("strategy", "optimizer"); optimiseur.insert("latencyMs", 431); const int sansPrevision = taille(QVariantMap{{"request", m_requetes.first()}}); const int r = taille(regles), o = taille(optimiseur); qInfo().noquote() << QStringLiteral("[mesure] requête réelle du banc (2 charges, sans prévision) : %1 o").arg(sansPrevision); qInfo().noquote() << QStringLiteral("[mesure] ligne rule-based, 4 charges, prévision 48 créneaux : %1 o — %2 o/jour à 1 cycle/min") .arg(r).arg(qint64(r) * 1440); qInfo().noquote() << QStringLiteral("[mesure] ligne optimiseur, plan de 48 créneaux × 4 actions : %1 o — %2 o/jour") .arg(o).arg(qint64(o) * 1440); // Le plafond par défaut doit tenir une journée PLEINE d'optimiseur au rythme mesuré au banc // (1 cycle/min), avec 15 % de marge pour les cycles déclenchés par un événement ; et une // journée rule-based quatre fois. QVERIFY2(qint64(o) * 1440 * 115 / 100 <= OptimizerTrace::PlafondDefautOctets, qPrintable(QStringLiteral("%1 o par ligne d'optimiseur").arg(o))); QVERIFY2(qint64(r) * 1440 * 4 <= OptimizerTrace::PlafondDefautOctets, qPrintable(QStringLiteral("%1 o par ligne rule-based").arg(r))); } void TestSocketScheduler::testTracerPairsTheResponseByRequestId() { // Le traceur seul : une réponse ne complète QUE la ligne de SA requête. En service, le // SocketScheduler écarte déjà les réponses obsolètes ; cette garde est la seconde. const QString d = m_dossierTraces.path() + "/appariement"; QDir().mkpath(d); OptimizerTrace::Reglage r; r.active = true; r.dossier = d; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); { OptimizerTracer tr(r); tr.noterCycle({{"t", "a"}, {"strategy", "rules"}}, t0, 7); tr.noterReponse(6, {{"result", QVariantMap{{"faux", true}}}}, 1); // une autre requête tr.noterReponse(0, {{"result", QVariantMap{{"faux", true}}}}, 1); // aucune requête tr.noterReponse(7, {{"result", QVariantMap{{"bon", true}}}}, 12); tr.noterReponse(7, {{"result", QVariantMap{{"double", true}}}}, 13); // déjà complétée } // le destructeur vide la file const QList l = traces(d, 1); QCOMPARE(l.count(), 1); QCOMPARE(l.first().value("response").toMap(), (QVariantMap{{"result", QVariantMap{{"bon", true}}}})); QCOMPARE(l.first().value("latencyMs").toInt(), 12); } void TestSocketScheduler::testAnEchoedTokenLeaksNowhere() { // Correctif A (brief 4 T7). Un optimiseur qui RECOPIE le token — avec les deux caractères que // JSON échappe — dans tout ce qu'il renvoie et que le moteur relaie : son nom et sa version au // handshake, l'identifiant et la stratégie du plan, le texte d'une action, un code hors // catalogue, une clé et une valeur de paramètre de motif, le texte d'une abstention, un loadId // inconnu, une notification. Le secret ne doit apparaître dans AUCUNE ligne de journal (tous // niveaux, nymea compris, hors la trace brute des données ENTRANTES de nymea — voir // testTokenIsWriteOnlyAndNeverLeaks), aucune trace, aucune réponse ni notification RPC. monter(); const QString secret = QStringLiteral("T7-echo\"du\\token-9876543210"); const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2); const QStringList fragments{secret, QString::fromUtf8(echappe), QStringLiteral("9876543210")}; QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(), QString("EnergyErrorNoError")); const QString d = activerTraces(); QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData); QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true")); Journal j; const QJsonObject handshakeAvant = m_handshake; m_handshake = handshakeResult(); m_handshake.insert("name", "écho " + secret); m_handshake.insert("protocolVersion", "1.1-" + secret); 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); // le secret a bien transité SocketScheduler *s = m_arb->socketScheduler(); QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; })); m_repondre = [this, secret](const QVariantMap &req) { QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret}, {"reasonCode", "SURPLUS_SETPOINT"}, {"reasonParams", QVariantMap{{"budgetW", secret}, {secret, 5}, {"setpointW", 1000}}}}), action(m_var, {{"powerW", 500}, {"reasonCode", secret}})}); p.insert("planId", secret); p.insert("strategy", secret); p.insert("diagnostics", QVariantMap{{"status", "optimal"}, {"solver", secret}}); return p; }; const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); cycle(t0, -3000); cycle(t0.addSecs(60), -3000); QVERIFY(s->optimiseurAuDernierCycle()); QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), QString("écho [token masqué]")); rpc("GetPlan"); m_arb->loadTelemetry(); injectAndWait("NymeaEnergy.GetLoadTelemetry"); m_repondre = [secret](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "écho " + secret}}; }; cycle(t0.addSecs(120), -3000); m_repondre = [this, secret](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}}), action(secret, {{"powerW", 1}})}); }; cycle(t0.addSecs(180), -3000); m_serveur->sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"method", secret}, {"params", QJsonObject{{secret, secret}}}}); settle(200); cycle(t0.addSecs(240), -3000); rpc("GetOptimizerStatus"); rpc("GetPlan"); // Le texte de l'optimiseur a bien coulé au journal — MASQUÉ. QVERIFY2(j.texte().contains("plan [token masqué] reçu"), qUtf8Printable(j.texte().right(3000))); QVERIFY(j.texte().contains("« écho [token masqué] »")); QVERIFY(j.texte().contains("abstention : écho [token masqué]")); QVERIFY(j.texte().contains("charge [token masqué] inconnue")); QCOMPARE(j.compte("recopie le token"), 1); // Aucune ligne du journal hors la trace brute ENTRANTE de nymea (la pose du token). for (const QString &f : fragments) { QStringList fuites; for (const QString &x : j.lignes(f)) if (!x.startsWith(QLatin1String("JsonRpcTraffic: "))) fuites << x.left(300); QVERIFY2(fuites.isEmpty(), qUtf8Printable(fuites.join('\n'))); } // Aucune réponse ni notification RPC (5 réponses au moins : 2 statuts, 2 plans, la télémétrie). QVERIFY2(paquets.count() >= 5, qPrintable(QString::number(paquets.count()))); for (const QList &p : paquets) for (const QString &f : fragments) QVERIFY2(!p.last().toByteArray().contains(f.toUtf8()), p.last().toByteArray().left(300).constData()); // Aucune trace. QVERIFY(traces(d, 5).count() >= 5); QByteArray tout; for (const QString &nom : fichiersTrace(d)) { QFile f(QDir(d).filePath(nom)); QVERIFY(f.open(QIODevice::ReadOnly)); tout += f.readAll(); } QVERIFY(tout.contains("[token masqué]")); for (const QString &f : fragments) QVERIFY2(!tout.contains(f.toUtf8()), qPrintable(f)); m_handshake = handshakeAvant; QFile::remove(OptimizerClient::defaultTokenPath()); QLoggingCategory::setFilterRules(QStringLiteral( "*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n" "NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false")); } QTEST_MAIN(TestSocketScheduler) // --- Lot contrat OpenRPC ---------------------------------------------------------------------- void TestSocketScheduler::cleanupTestCase() { SimulationBase::cleanupTestCase(); jugerLesRequetesDuMoteur(); } void TestSocketScheduler::jugerLesRequetesDuMoteur() { // TOUT ce que le moteur a émis dans ce processus — Handshake du client, GetPlan du sérialiseur // réel sur le contexte réel de l'arbitre —, jugé par le validateur publié contre le contrat // EMBARQUÉ. Appelé aussi par monter() (qui relance le serveur) : chaque banc est jugé. const QList requetes = FakeOptimizer::capture(); FakeOptimizer::capture().clear(); if (requetes.isEmpty()) return; QTemporaryDir dir; QVERIFY(dir.isValid()); const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()), {QStringLiteral("--requests-only")}); QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("requêtes du moteur NON CONFORMES au contrat embarqué :\n") + v.sortie.right(4000))); } void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() { // Le contrat SERVI par NymeaEnergy.GetOptimizerProtocol est celui qu'embarque le binaire, et // celui de docs/ ; et ce que le moteur ENVOIE à un optimiseur y est conforme. Garde de // pertinence : des Handshake et des GetPlan ont réellement été jugés — un jugement sur une // capture vide ne dirait rien. FakeOptimizer::capture().clear(); monter(); m_repondre = planValide(); const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); for (int i = 0; i < 3; ++i) cycle(t0.addSecs(60 * i), -3000); const QVariantMap r = rpc("GetOptimizerProtocol"); QCOMPARE(r.value("protocolVersion").toString(), QString("1.1")); const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); QVERIFY(!embarque.isEmpty()); QCOMPARE(servi.value("openrpc").toString(), QString("1.3.2")); QVERIFY2(servi == embarque, "GetOptimizerProtocol ne sert pas le contrat embarqué"); QFile f(Contrat::schemaDuDepot()); QVERIFY(f.open(QIODevice::ReadOnly)); QVERIFY2(QJsonDocument::fromJson(f.readAll()).object() == servi, "le contrat servi n'est pas celui de docs/"); // Jugé avec le contrat SERVI, pas une copie : c'est celui qu'un auteur d'optimiseur lirait. QTemporaryDir dir; QVERIFY(dir.isValid()); const QList requetes = FakeOptimizer::capture(); FakeOptimizer::capture().clear(); const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, QJsonDocument(servi).toJson()), {QStringLiteral("--requests-only")}); QVERIFY2(v.code == 0, qUtf8Printable(v.sortie.right(4000))); QVERIFY2(v.compte("Handshake.params") >= 1, qUtf8Printable(v.sortie.right(1000))); QVERIFY2(v.compte("GetPlan.params") >= 3, qUtf8Printable(v.sortie.right(1000))); } #include "testsocketscheduler.moc"