diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index c2ac7ab..15e2ef5 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -106,6 +106,17 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q return applied; } +void EtmVariableLoadAdapter::applySafeState(const QDateTime &now) +{ + LoadAction sur; + sur.loadId = m_id; + sur.kind = LoadAction::Setpoint; + sur.powerW = 0; + sur.force = true; + sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)"); + applyAction(sur, now); +} + // ---- privé --------------------------------------------------------------- double EtmVariableLoadAdapter::readCurrentPowerW() const diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 29df9ad..da0e4fa 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -116,6 +116,9 @@ public: //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. void clearFault() override {} + //! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle. + void applySafeState(const QDateTime &now) override; + /*! * \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). * \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 5dc081f..5e2912c 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -81,6 +81,10 @@ public: //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. void clearFault() override {} + //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas + //! être « désactivé » par ce chemin. \param now Ignoré. + void applySafeState(const QDateTime &now) override { Q_UNUSED(now) } + /*! * \brief Borne VE sous-jacente (lecture). * \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction). diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index b03326f..bd96ef0 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -106,4 +106,26 @@ public: * DÉCLARER. La généralisation est portée par **ECS-414**. */ virtual void clearFault() = 0; + + /*! + * \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413). + * + * Appelé quand la charge est désactivée (\c enabled: false) ou retirée de la + * configuration — un acte DÉLIBÉRÉ de l'opérateur. Sans cela, l'adaptateur est détruit et + * le matériel reste dans son DERNIER ÉTAT COMMANDÉ : constaté au banc le 2026-08-09, trois + * relais laissés fermés juste avant une intervention de câblage. + * + * \param now Temps de cycle. Passe par le chemin d'action normal avec \c force = true, + * comme le mode dégradé L2 : l'arrêt prime sur les verrous. + * + * \warning **NE PAS appeler à l'arrêt du plugin ni au redémarrage de nymead.** L'état doit + * y être CONSERVÉ — c'est ce qu'ECS-411 relit au démarrage, et couper l'eau chaude à + * chaque redémarrage de service serait une régression. La distinction est + * intentionnelle : la désactivation est délibérée, un redémarrage ne l'est pas. + * + * \note L'état sûr est PROPRE à chaque adaptateur — relais ouverts pour un routeur, + * consigne 0 W pour une charge continue, mais **état 2** pour une PAC SG-Ready, jamais + * le blocage (\c docs/SAFETY.md). Une formulation « tout couper » serait fausse. + */ + virtual void applySafeState(const QDateTime &now) = 0; }; diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index fbde38a..f2c0a06 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -439,6 +439,20 @@ void RelayRouter::settleTransition() } } +void RelayRouter::applySafeState(const QDateTime &now) +{ + // ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action + // NORMAL avec force = true, exactement comme le repli L2 : c'est le mécanisme qui existe + // déjà pour contourner les verrous quand l'arrêt prime. Rien à inventer. + LoadAction sur; + sur.loadId = m_id; + sur.kind = LoadAction::Setpoint; + sur.powerW = 0; + sur.force = true; + sur.reason = QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)"); + applyAction(sur, now); +} + void RelayRouter::clearFault() { if (!m_faulted) diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index cac893b..ddc1a1c 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -104,6 +104,13 @@ public: */ void clearFault() override; + //! \brief État sûr du routeur : TOUS relais ouverts (ECS-413). + //! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés. + void applySafeState(const QDateTime &now) override; + + //! \return Vrai tant qu'une écriture émise n'est pas acquittée (ECS-410). + bool writesPending() const { return m_pending > 0; } + //! \return Vrai si la charge est en défaut (plus aucune commande émise). bool faulted() const { return m_faulted; } diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index 7225a1e..e7221f0 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -128,6 +128,20 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime return applied; } +void SgReadyAdapter::applySafeState(const QDateTime &now) +{ + // ECS-413 — état sûr d'une PAC = état 2 (normal, mains off). PAS l'état 1 : bloquer une + // PAC n'est pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible + // (SAFETY.md, et même choix que le repli L2). + LoadAction sur; + sur.loadId = m_id; + sur.kind = LoadAction::State; + sur.state = 2; + sur.force = true; + sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)"); + applyAction(sur, now); +} + // ---- privé --------------------------------------------------------------- void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxState) const diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index b19d9e3..f1d0947 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -100,6 +100,14 @@ public: //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. void clearFault() override {} + /*! + * \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage). + * \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold). + * \note Couper une PAC en la bloquant serait une régression de sécurité, pas une mise en + * sécurité (\c docs/SAFETY.md). C'est le même état que le repli du mode dégradé L2. + */ + void applySafeState(const QDateTime &now) override; + /*! * \brief État SG-Ready courant (1-4). * \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 7c12bcf..b547538 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -234,11 +234,30 @@ void EnergyArbitrator::rebuildLoadAdapters() ++created; } - // Ce qui reste dans m_loadAdapters n'est plus référencé par la config : à détruire. - for (ILoadAdapter *a : m_loadAdapters) - if (QObject *o = dynamic_cast(a)) - o->deleteLater(); + // ECS-413 — ce qui reste dans m_loadAdapters n'est plus référencé par la config : + // désactivé (enabled: false) ou retiré. On l'amène à son ÉTAT SÛR **avant** de le + // détruire, sans quoi le matériel resterait dans son dernier état commandé — constaté + // au banc le 2026-08-09, trois relais laissés fermés juste avant un câblage. + // + // Ne s'applique QU'ICI : rien de tel à l'arrêt du plugin ni au redémarrage de nymead, + // où l'état doit être conservé (c'est ce qu'ECS-411 relit). La désactivation est un acte + // délibéré de l'opérateur ; un redémarrage n'en est pas un. + // + // Les charges CONSERVÉES ne passent pas par ici : un simple changement de rang ne coupe + // donc rien, et ECS-412 reste entier. const int removed = m_loadAdapters.count(); + const QDateTime now = QDateTime::currentDateTime(); + for (ILoadAdapter *a : m_loadAdapters) { + a->applySafeState(now); + QObject *o = dynamic_cast(a); + if (!o) + continue; + // ECS-410 : les écritures sont asynchrones et `this` sert de contexte de connexion. + // Détruire tout de suite couperait les acquittements en vol — on ne saurait donc pas + // si la mise en sécurité a abouti, précisément dans le cas où elle échoue. + // deleteLater() laisse le cycle d'événements les délivrer d'abord. + o->deleteLater(); + } m_loadAdapters = kept; m_builtFrom = keptFrom; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d869837..86b270d 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1226,6 +1226,78 @@ void Simulation::testEcsPartialFailure() #endif } +void Simulation::testEcsDisableLeavesSafeState() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEcsDisableLeavesSafeState nécessite ETM_ARBITRATOR."); +#else + // [ECS-413] Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une + // charge au palier 3500 W a détruit l'adaptateur en laissant les TROIS relais fermés, + // juste avant une intervention de câblage. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QUuid rA = addPowerSwitch(2000, 26661); + Thing *tA = tm->findConfiguredThing(rA); + QVERIFY(tA); + + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-safestate.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + + auto cfg = [&](bool enabled, int priority) { + LoadConfigs cs; + cs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", priority}, {"enabled", enabled}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}})); + return cs; + }; + + QString err; + QVERIFY2(store->setConfigs(cfg(true, 1), &err), err.toUtf8()); + + // Surplus large → le relais se ferme. + meter->setStateValue("currentPower", -2500); + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QCoreApplication::processEvents(); + QCOMPARE(tA->stateValue("power").toBool(), true); + + // --- CAS NÉGATIF, obligatoire : rebuild SANS désactivation --------------------------- + // Seul le rang change. ECS-412 conserve l'adaptateur, donc AUCUNE mise en sécurité ne + // doit être déclenchée : sans ce cas, chaque changement de priorité couperait la charge. + QVERIFY2(store->setConfigs(cfg(true, 2), &err), err.toUtf8()); + QTest::qWait(300); + QCOMPARE(tA->stateValue("power").toBool(), true); + + // --- CAS POSITIF : désactivation ------------------------------------------------------ + QVERIFY2(store->setConfigs(cfg(false, 2), &err), err.toUtf8()); + QTest::qWait(300); + QCOMPARE(tA->stateValue("power").toBool(), false); // état sûr appliqué AVANT destruction + + // Et il le reste : plus personne ne pilote, rien ne doit le refermer même sous surplus. + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 5, 0))); + QTest::qWait(300); + QCOMPARE(tA->stateValue("power").toBool(), false); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index e725865..7832ce3 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -102,6 +102,10 @@ private slots: // [ECS-410] Échec d'écriture en cours de transition : retour arrière, arrêt total, puis // état de défaut collant. Chaque marche de l'échelle a son cas, et la levée est délibérée. void testEcsPartialFailure(); + // [ECS-413] Désactiver une charge la laisse dans l'état SÛR de son adaptateur, pas dans + // son dernier état commandé — et un rebuild SANS désactivation ne coupe rien (cas négatif + // obligatoire, sans lui ECS-412 serait annulé). + void testEcsDisableLeavesSafeState(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);