fix(etm): ECS-413 — désactiver une charge la laisse en état sûr
Constat de banc du 2026-08-09 : un SetLoadConfig posant enabled: false sur une
charge alors au palier 3500 W détruisait l'adaptateur en laissant les TROIS RELAIS
FERMÉS, juste avant une intervention de câblage. Plus personne ne les commandait ;
ils y seraient restés indéfiniment.
applySafeState(now) est ajouté à ILoadAdapter, PURE VIRTUELLE : l'état sûr est
propre à chaque adaptateur et une formulation « tout couper » serait fausse.
- RelayRouter : tous relais ouverts
- EtmVariableLoadAdapter : consigne 0 W
- SgReadyAdapter : ÉTAT 2 (normal, mains off) — JAMAIS 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).
- EvAdapter : sans effet, il n'est pas construit depuis LoadConfig.
L'application passe par le chemin d'action NORMAL avec force = true, celui du
mode dégradé L2 : le mécanisme de contournement des verrous existait déjà.
ORDRE, et c'est le point qui dépendait d'ECS-410 : l'état sûr est appliqué AVANT
la destruction, et la destruction passe par deleteLater(). Les écritures d'ECS-410
sont asynchrones avec `this` en contexte de connexion — détruire immédiatement
couperait les acquittements en vol, et on ne saurait pas si la mise en sécurité a
abouti, précisément dans le cas où elle échoue.
PÉRIMÈTRE BORNÉ. Rien de tout cela à l'arrêt du plugin ni au redémarrage de
nymead : l'état doit y être CONSERVÉ, c'est ce qu'ECS-411 relit, et couper l'eau
chaude à chaque redémarrage de service serait une régression. 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 par le rebuild incrémental ne passent pas par ce chemin.
Test testEcsDisableLeavesSafeState, avec son CAS NÉGATIF en premier : un rebuild
qui ne change que le rang ne coupe rien — sans lui, ECS-412 serait annulé et
chaque changement de priorité couperait la charge. Puis le cas positif :
désactivation, relais ouvert, et il le reste même sous surplus au cycle suivant.
Build amd64 0 erreur. Simulation : 17/17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
92d0bef4ac
commit
dd28749b6b
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<QObject *>(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<QObject *>(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;
|
||||
|
||||
@ -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<EnergyArbitrator *>(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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user