diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 74ee561..ba6d1cf 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -30,22 +30,24 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, , m_scheduler(new RuleBasedScheduler(this, this)) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- - // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4) ; - // grâce au démarrage : on initialise le timestamp pour éviter un dégradé immédiat. - m_lastMeterUpdate = QDateTime::currentDateTime(); + // La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre + // et reste testable par injection (symétrique de simulationCallUpdate). Seuls les + // DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en + // simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130). +#ifndef ENERGY_SIMULATION + m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat) + // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4). connect(em, &EnergyManager::powerBalanceChanged, this, [this]() { - m_lastMeterUpdate = QDateTime::currentDateTime(); - if (m_degradedMode) { - qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2."; - m_degradedMode = false; - emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant) - } + recordMeterUpdate(QDateTime::currentDateTime()); }); // QTimer (et non signal) : doit rester actif quand le compteur est muet. m_meterWatchdog = new QTimer(this); m_meterWatchdog->setInterval(MeterWatchdogPeriodMs); connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick); m_meterWatchdog->start(); +#else + Q_UNUSED(em) +#endif qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé."; } @@ -204,11 +206,26 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime } void EnergyArbitrator::onMeterWatchdogTick() +{ + // Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable. + evaluateMeterFreshness(QDateTime::currentDateTime()); +} + +void EnergyArbitrator::recordMeterUpdate(const QDateTime &now) +{ + m_lastMeterUpdate = now; + if (m_degradedMode) { + qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2."; + m_degradedMode = false; + emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant) + } +} + +void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now) { if (!m_lastMeterUpdate.isValid()) return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent). - const QDateTime now = QDateTime::currentDateTime(); const qint64 silentS = m_lastMeterUpdate.secsTo(now); if (silentS <= MeterSilenceThresholdS) return; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 48081c5..da30745 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -92,6 +92,29 @@ public: */ bool degradedMode() const override { return m_degradedMode; } + /*! + * \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2). + * + * Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort + * (\c degradedMode=false + notification). \p now = temps de cycle. + * \note Logique injectable (temps en paramètre) — en production appelée par le + * handler \c powerBalanceChanged ; en simulation/test appelée directement. Le + * déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION. + */ + void recordMeterUpdate(const QDateTime &now); + + /*! + * \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s. + * + * Si \c now − \c m_lastMeterUpdate > 90 s et pas déjà dégradé → \c applyDegradedMode(). + * Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle. + * \note Logique injectable — en production appelée par \c onMeterWatchdogTick() (QTimer + * horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en + * simulation/test appelée directement avec le temps simulé. Symétrique de + * \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection. + */ + void evaluateMeterFreshness(const QDateTime &now); + protected: /*! * \brief Boucle principale ETM — surcharge SmartChargingManager::update(). diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 7b0c1e0..b67e362 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -141,6 +141,87 @@ void Simulation::testEcsSurplusPV() #endif } +void Simulation::testMeterSilentFallback() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arbitrator = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)"); + + ThingManager *thingManager = NymeaCore::instance()->thingManager(); + + // --- Root meter + un relais ECS (palier 1 = 2400 W) --- + QUuid meterThingId = addMeter(); + QVERIFY2(!meterThingId.isNull(), "meter thingId invalide"); + m_experiencePlugin->energyManager()->setRootMeter(meterThingId); + Thing *meterThing = thingManager->findConfiguredThing(meterThingId); + QVERIFY(meterThing); + meterThing->setStateValue("connected", true); + + QUuid relayAId = addPowerSwitch(2400, 26661); + QVERIFY(!relayAId.isNull()); + Thing *relayA = thingManager->findConfiguredThing(relayAId); + QVERIFY(relayA); + + // minOn = 300 s (protection compresseur) ; minOff = 0. + EcsRelayAdapter *ecs = new EcsRelayAdapter( + thingManager, "ecs-fallback-test", "Chauffe-eau (test repli)", + QList({0, 2400}), + QList>({ {}, {relayAId.toString()} }), + 300, 0, 1, arbitrator); + arbitrator->registerEcsAdapter(ecs); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); }; + auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); }; + + // --- ECS allumé sur surplus, compteur frais à T0 --- + arbitrator->recordMeterUpdate(t0); + setMeterW(-2500); cycle(t0); // surplus 2500 → palier 1 (lastSwitch ECS = T0) + QCOMPARE(ecs->currentStage(), 1); + QCOMPARE(relayA->stateValue("power").toBool(), true); + QVERIFY(!arbitrator->degradedMode()); + + // --- Compteur muet > 90 s → mode dégradé ; le repli force=true coupe l'ECS MÊME sous minOn --- + // (ECS commuté à T0, elapsed 91 s < minOn 300 → normalement verrouillé ; force=true bypasse.) + arbitrator->evaluateMeterFreshness(t0.addSecs(91)); + QCoreApplication::processEvents(); + QVERIFY(arbitrator->degradedMode()); + QCOMPARE(ecs->currentStage(), 0); // coupé malgré minOn (sécurité bypasse l'anti-flapping) + QCOMPARE(relayA->stateValue("power").toBool(), false); + + // --- STABILITÉ : compteur toujours muet, plusieurs update() → l'ECS RESTE à 0 --- + // (planification suspendue : pas de getPlan() sur cache mort qui rallumerait l'ECS.) + // On garde même un faux surplus au compteur pour piéger une éventuelle replanification. + setMeterW(-3000); + foreach (int dt, QList({92, 120, 200, 280})) { + cycle(t0.addSecs(dt)); + QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence"); + QCOMPARE(ecs->currentStage(), 0); + QCOMPARE(relayA->stateValue("power").toBool(), false); + } + + // --- REPRISE : le compteur re-parle → degradedMode retombe → recalcul normal --- + arbitrator->recordMeterUpdate(t0.addSecs(300)); + QVERIFY(!arbitrator->degradedMode()); + + // Preuve "recalcul, pas restauration d'ancienne consigne" : surplus FAIBLE → l'ECS + // RESTE éteint (recalculé), il ne revient PAS à son ancien palier 1. + setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 W < palier 1 → éteint + QCOMPARE(ecs->currentStage(), 0); + + // Puis surplus suffisant → l'ECS resuit le surplus normalement. + setMeterW(-2500); cycle(t0.addSecs(302)); + QCOMPARE(ecs->currentStage(), 1); + QCOMPARE(relayA->stateValue("power").toBool(), true); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index ace9e86..4ffdf0e 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -60,6 +60,10 @@ private slots: // et protection compresseur (import < minOn → RESTE ; import > minOn → déleste). void testEcsSurplusPV(); + // Watchdog L2 : compteur muet >90 s → mode dégradé (ECS off force=true, bypass minOn), + // planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur. + void testMeterSilentFallback(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);