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:
Patrick Schurig 2026-08-09 11:36:38 +02:00
parent 92d0bef4ac
commit dd28749b6b
11 changed files with 182 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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;
};

View File

@ -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)

View File

@ -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; }

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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);